Files
Sam Morrow bc5d08da5c fix: track HTTP status code errors in context for observability (#1630)
Add NewGitHubAPIStatusErrorResponse helper function to properly track
GitHub API errors when the API call succeeds but returns an unexpected
HTTP status code (e.g., 404, 422, 500).

Previously, these errors were returned via utils.NewToolResultError which
bypasses the context-based error tracking used by the remote server's
error_categorizer.go for observability metrics. This resulted in 100%
tool call success rates in observability even when errors occurred.

The fix adds a new helper function that:
1. Creates a synthetic error from the status code and response body
2. Records the error in context via NewGitHubAPIErrorResponse
3. Returns the MCP error result to the client

Updated all tool files to use the new pattern for status code errors:
- pullrequests.go: 12 fixes
- repositories.go: 18 fixes
- issues.go: 10 fixes
- notifications.go: 6 fixes
- projects.go: 5 fixes
- search.go: 3 fixes
- search_utils.go: 1 fix
- gists.go: 4 fixes
- code_scanning.go: 2 fixes
- dependabot.go: 2 fixes
- secret_scanning.go: 2 fixes
- security_advisories.go: 4 fixes

Total: ~69 error paths now properly tracked.

Note: Parameter validation errors (RequiredParam failures) and internal
I/O errors (io.ReadAll failures) intentionally continue to use
utils.NewToolResultError as they are not GitHub API errors.
2025-12-17 17:03:03 +01:00

434 lines
14 KiB
Go

package github
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/go-github/v79/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// SearchRepositories creates a tool to search for GitHub repositories.
func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTool {
schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"query": {
Type: "string",
Description: "Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering.",
},
"sort": {
Type: "string",
Description: "Sort repositories by field, defaults to best match",
Enum: []any{"stars", "forks", "help-wanted-issues", "updated"},
},
"order": {
Type: "string",
Description: "Sort order",
Enum: []any{"asc", "desc"},
},
"minimal_output": {
Type: "boolean",
Description: "Return minimal repository information (default: true). When false, returns full GitHub API repository objects.",
Default: json.RawMessage(`true`),
},
},
Required: []string{"query"},
}
WithPagination(schema)
return NewTool(
ToolsetMetadataRepos,
mcp.Tool{
Name: "search_repositories",
Description: t("TOOL_SEARCH_REPOSITORIES_DESCRIPTION", "Find GitHub repositories by name, description, readme, topics, or other metadata. Perfect for discovering projects, finding examples, or locating specific repositories across GitHub."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_SEARCH_REPOSITORIES_USER_TITLE", "Search repositories"),
ReadOnlyHint: true,
},
InputSchema: schema,
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
query, err := RequiredParam[string](args, "query")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
sort, err := OptionalParam[string](args, "sort")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
order, err := OptionalParam[string](args, "order")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
pagination, err := OptionalPaginationParams(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
minimalOutput, err := OptionalBoolParamWithDefault(args, "minimal_output", true)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
opts := &github.SearchOptions{
Sort: sort,
Order: order,
ListOptions: github.ListOptions{
Page: pagination.Page,
PerPage: pagination.PerPage,
},
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
result, resp, err := client.Search.Repositories(ctx, query, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
fmt.Sprintf("failed to search repositories with query '%s'", query),
resp,
err,
), nil, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search repositories", resp, body), nil, nil
}
// Return either minimal or full response based on parameter
var r []byte
if minimalOutput {
minimalRepos := make([]MinimalRepository, 0, len(result.Repositories))
for _, repo := range result.Repositories {
minimalRepo := MinimalRepository{
ID: repo.GetID(),
Name: repo.GetName(),
FullName: repo.GetFullName(),
Description: repo.GetDescription(),
HTMLURL: repo.GetHTMLURL(),
Language: repo.GetLanguage(),
Stars: repo.GetStargazersCount(),
Forks: repo.GetForksCount(),
OpenIssues: repo.GetOpenIssuesCount(),
Private: repo.GetPrivate(),
Fork: repo.GetFork(),
Archived: repo.GetArchived(),
DefaultBranch: repo.GetDefaultBranch(),
}
if repo.UpdatedAt != nil {
minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z")
}
if repo.CreatedAt != nil {
minimalRepo.CreatedAt = repo.CreatedAt.Format("2006-01-02T15:04:05Z")
}
if repo.Topics != nil {
minimalRepo.Topics = repo.Topics
}
minimalRepos = append(minimalRepos, minimalRepo)
}
minimalResult := &MinimalSearchRepositoriesResult{
TotalCount: result.GetTotal(),
IncompleteResults: result.GetIncompleteResults(),
Items: minimalRepos,
}
r, err = json.Marshal(minimalResult)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal minimal response", err), nil, nil
}
} else {
r, err = json.Marshal(result)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal full response", err), nil, nil
}
}
return utils.NewToolResultText(string(r)), nil, nil
}
},
)
}
// SearchCode creates a tool to search for code across GitHub repositories.
func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool {
schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"query": {
Type: "string",
Description: "Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more.",
},
"sort": {
Type: "string",
Description: "Sort field ('indexed' only)",
},
"order": {
Type: "string",
Description: "Sort order for results",
Enum: []any{"asc", "desc"},
},
},
Required: []string{"query"},
}
WithPagination(schema)
return NewTool(
ToolsetMetadataRepos,
mcp.Tool{
Name: "search_code",
Description: t("TOOL_SEARCH_CODE_DESCRIPTION", "Fast and precise code search across ALL GitHub repositories using GitHub's native search engine. Best for finding exact symbols, functions, classes, or specific code patterns."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_SEARCH_CODE_USER_TITLE", "Search code"),
ReadOnlyHint: true,
},
InputSchema: schema,
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
query, err := RequiredParam[string](args, "query")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
sort, err := OptionalParam[string](args, "sort")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
order, err := OptionalParam[string](args, "order")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
pagination, err := OptionalPaginationParams(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
opts := &github.SearchOptions{
Sort: sort,
Order: order,
ListOptions: github.ListOptions{
PerPage: pagination.PerPage,
Page: pagination.Page,
},
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
result, resp, err := client.Search.Code(ctx, query, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
fmt.Sprintf("failed to search code with query '%s'", query),
resp,
err,
), nil, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search code", resp, body), nil, nil
}
r, err := json.Marshal(result)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
}
},
)
}
func userOrOrgHandler(accountType string, deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
query, err := RequiredParam[string](args, "query")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
sort, err := OptionalParam[string](args, "sort")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
order, err := OptionalParam[string](args, "order")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
pagination, err := OptionalPaginationParams(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
opts := &github.SearchOptions{
Sort: sort,
Order: order,
ListOptions: github.ListOptions{
PerPage: pagination.PerPage,
Page: pagination.Page,
},
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
searchQuery := query
if !hasTypeFilter(query) {
searchQuery = "type:" + accountType + " " + query
}
result, resp, err := client.Search.Users(ctx, searchQuery, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
fmt.Sprintf("failed to search %ss with query '%s'", accountType, query),
resp,
err,
), nil, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, fmt.Sprintf("failed to search %ss", accountType), resp, body), nil, nil
}
minimalUsers := make([]MinimalUser, 0, len(result.Users))
for _, user := range result.Users {
if user.Login != nil {
mu := MinimalUser{
Login: user.GetLogin(),
ID: user.GetID(),
ProfileURL: user.GetHTMLURL(),
AvatarURL: user.GetAvatarURL(),
}
minimalUsers = append(minimalUsers, mu)
}
}
minimalResp := &MinimalSearchUsersResult{
TotalCount: result.GetTotal(),
IncompleteResults: result.GetIncompleteResults(),
Items: minimalUsers,
}
if result.Total != nil {
minimalResp.TotalCount = *result.Total
}
if result.IncompleteResults != nil {
minimalResp.IncompleteResults = *result.IncompleteResults
}
r, err := json.Marshal(minimalResp)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
}
}
// SearchUsers creates a tool to search for GitHub users.
func SearchUsers(t translations.TranslationHelperFunc) inventory.ServerTool {
schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"query": {
Type: "string",
Description: "User search query. Examples: 'john smith', 'location:seattle', 'followers:>100'. Search is automatically scoped to type:user.",
},
"sort": {
Type: "string",
Description: "Sort users by number of followers or repositories, or when the person joined GitHub.",
Enum: []any{"followers", "repositories", "joined"},
},
"order": {
Type: "string",
Description: "Sort order",
Enum: []any{"asc", "desc"},
},
},
Required: []string{"query"},
}
WithPagination(schema)
return NewTool(
ToolsetMetadataUsers,
mcp.Tool{
Name: "search_users",
Description: t("TOOL_SEARCH_USERS_DESCRIPTION", "Find GitHub users by username, real name, or other profile information. Useful for locating developers, contributors, or team members."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_SEARCH_USERS_USER_TITLE", "Search users"),
ReadOnlyHint: true,
},
InputSchema: schema,
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return userOrOrgHandler("user", deps)
},
)
}
// SearchOrgs creates a tool to search for GitHub organizations.
func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool {
schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"query": {
Type: "string",
Description: "Organization search query. Examples: 'microsoft', 'location:california', 'created:>=2025-01-01'. Search is automatically scoped to type:org.",
},
"sort": {
Type: "string",
Description: "Sort field by category",
Enum: []any{"followers", "repositories", "joined"},
},
"order": {
Type: "string",
Description: "Sort order",
Enum: []any{"asc", "desc"},
},
},
Required: []string{"query"},
}
WithPagination(schema)
return NewTool(
ToolsetMetadataOrgs,
mcp.Tool{
Name: "search_orgs",
Description: t("TOOL_SEARCH_ORGS_DESCRIPTION", "Find GitHub organizations by name, location, or other organization metadata. Ideal for discovering companies, open source foundations, or teams."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_SEARCH_ORGS_USER_TITLE", "Search organizations"),
ReadOnlyHint: true,
},
InputSchema: schema,
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return userOrOrgHandler("org", deps)
},
)
}