Files
github--github-mcp-server/pkg/github/context_tools.go
T
Sam Morrow 82c493056e
Build and Test Go Project / build (ubuntu-latest) (push) Has been cancelled
Build and Test Go Project / build (windows-latest) (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Docker / build (push) Has been cancelled
Build and Test Go Project / build (macos-latest) (push) Has been cancelled
GoReleaser Release / release (push) Has been cancelled
License Check / license-check (push) Has been cancelled
Publish to MCP Registry / publish (push) Has been cancelled
fix: include empty properties in get_me schema for OpenAI compatibility
OpenAI strict mode requires the `properties` field to be present in
object schemas, even when empty. The jsonschema library uses omitempty
which causes empty maps to be omitted during JSON serialization.

This change uses json.RawMessage to bypass the library's serialization
and explicitly include `"properties": {}` in the output.

Fixes #1548
2025-12-08 11:15:21 +01:00

268 lines
9.1 KiB
Go

package github
import (
"context"
"encoding/json"
"time"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shurcooL/githubv4"
)
// UserDetails contains additional fields about a GitHub user not already
// present in MinimalUser. Used by get_me context tool but omitted from search_users.
type UserDetails struct {
Name string `json:"name,omitempty"`
Company string `json:"company,omitempty"`
Blog string `json:"blog,omitempty"`
Location string `json:"location,omitempty"`
Email string `json:"email,omitempty"`
Hireable bool `json:"hireable,omitempty"`
Bio string `json:"bio,omitempty"`
TwitterUsername string `json:"twitter_username,omitempty"`
PublicRepos int `json:"public_repos"`
PublicGists int `json:"public_gists"`
Followers int `json:"followers"`
Following int `json:"following"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PrivateGists int `json:"private_gists,omitempty"`
TotalPrivateRepos int64 `json:"total_private_repos,omitempty"`
OwnedPrivateRepos int64 `json:"owned_private_repos,omitempty"`
}
// GetMe creates a tool to get details of the authenticated user.
func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) {
return mcp.Tool{
Name: "get_me",
Description: t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_GET_ME_USER_TITLE", "Get my user profile"),
ReadOnlyHint: true,
},
// Use json.RawMessage to ensure "properties" is included even when empty.
// OpenAI strict mode requires the properties field to be present.
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
},
mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) {
client, err := getClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
user, res, err := client.Users.Get(ctx, "")
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get user",
res,
err,
), nil, err
}
// Create minimal user representation instead of returning full user object
minimalUser := MinimalUser{
Login: user.GetLogin(),
ID: user.GetID(),
ProfileURL: user.GetHTMLURL(),
AvatarURL: user.GetAvatarURL(),
Details: &UserDetails{
Name: user.GetName(),
Company: user.GetCompany(),
Blog: user.GetBlog(),
Location: user.GetLocation(),
Email: user.GetEmail(),
Hireable: user.GetHireable(),
Bio: user.GetBio(),
TwitterUsername: user.GetTwitterUsername(),
PublicRepos: user.GetPublicRepos(),
PublicGists: user.GetPublicGists(),
Followers: user.GetFollowers(),
Following: user.GetFollowing(),
CreatedAt: user.GetCreatedAt().Time,
UpdatedAt: user.GetUpdatedAt().Time,
PrivateGists: user.GetPrivateGists(),
TotalPrivateRepos: user.GetTotalPrivateRepos(),
OwnedPrivateRepos: user.GetOwnedPrivateRepos(),
},
}
return MarshalledTextResult(minimalUser), nil, nil
})
}
type TeamInfo struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
}
type OrganizationTeams struct {
Org string `json:"org"`
Teams []TeamInfo `json:"teams"`
}
func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) {
return mcp.Tool{
Name: "get_teams",
Description: t("TOOL_GET_TEAMS_DESCRIPTION", "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials"),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_GET_TEAMS_TITLE", "Get teams"),
ReadOnlyHint: true,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"user": {
Type: "string",
Description: t("TOOL_GET_TEAMS_USER_DESCRIPTION", "Username to get teams for. If not provided, uses the authenticated user."),
},
},
},
},
func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
user, err := OptionalParam[string](args, "user")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
var username string
if user != "" {
username = user
} else {
client, err := getClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
userResp, res, err := client.Users.Get(ctx, "")
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get user",
res,
err,
), nil, nil
}
username = userResp.GetLogin()
}
gqlClient, err := getGQLClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil, nil
}
var q struct {
User struct {
Organizations struct {
Nodes []struct {
Login githubv4.String
Teams struct {
Nodes []struct {
Name githubv4.String
Slug githubv4.String
Description githubv4.String
}
} `graphql:"teams(first: 100, userLogins: [$login])"`
}
} `graphql:"organizations(first: 100)"`
} `graphql:"user(login: $login)"`
}
vars := map[string]interface{}{
"login": githubv4.String(username),
}
if err := gqlClient.Query(ctx, &q, vars); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find teams", err), nil, nil
}
var organizations []OrganizationTeams
for _, org := range q.User.Organizations.Nodes {
orgTeams := OrganizationTeams{
Org: string(org.Login),
Teams: make([]TeamInfo, 0, len(org.Teams.Nodes)),
}
for _, team := range org.Teams.Nodes {
orgTeams.Teams = append(orgTeams.Teams, TeamInfo{
Name: string(team.Name),
Slug: string(team.Slug),
Description: string(team.Description),
})
}
organizations = append(organizations, orgTeams)
}
return MarshalledTextResult(organizations), nil, nil
}
}
func GetTeamMembers(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) {
return mcp.Tool{
Name: "get_team_members",
Description: t("TOOL_GET_TEAM_MEMBERS_DESCRIPTION", "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials"),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_GET_TEAM_MEMBERS_TITLE", "Get team members"),
ReadOnlyHint: true,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"org": {
Type: "string",
Description: t("TOOL_GET_TEAM_MEMBERS_ORG_DESCRIPTION", "Organization login (owner) that contains the team."),
},
"team_slug": {
Type: "string",
Description: t("TOOL_GET_TEAM_MEMBERS_TEAM_SLUG_DESCRIPTION", "Team slug"),
},
},
Required: []string{"org", "team_slug"},
},
},
func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
org, err := RequiredParam[string](args, "org")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
teamSlug, err := RequiredParam[string](args, "team_slug")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
gqlClient, err := getGQLClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil, nil
}
var q struct {
Organization struct {
Team struct {
Members struct {
Nodes []struct {
Login githubv4.String
}
} `graphql:"members(first: 100)"`
} `graphql:"team(slug: $teamSlug)"`
} `graphql:"organization(login: $org)"`
}
vars := map[string]interface{}{
"org": githubv4.String(org),
"teamSlug": githubv4.String(teamSlug),
}
if err := gqlClient.Query(ctx, &q, vars); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get team members", err), nil, nil
}
var members []string
for _, member := range q.Organization.Team.Members.Nodes {
members = append(members, string(member.Login))
}
return MarshalledTextResult(members), nil, nil
}
}