chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,674 @@
|
||||
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/ifc"
|
||||
"github.com/github/github-mcp-server/pkg/inventory"
|
||||
"github.com/github/github-mcp-server/pkg/scopes"
|
||||
"github.com/github/github-mcp-server/pkg/translations"
|
||||
"github.com/github/github-mcp-server/pkg/utils"
|
||||
"github.com/google/go-github/v89/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,
|
||||
},
|
||||
[]scopes.Scope{scopes.Repo},
|
||||
func(ctx context.Context, deps ToolDependencies, _ *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
|
||||
}
|
||||
}
|
||||
|
||||
callResult := utils.NewToolResultText(string(r))
|
||||
attachSearchRepositoriesIFCLabel(ctx, deps, result.Repositories, callResult)
|
||||
return callResult, nil, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// attachSearchRepositoriesIFCLabel joins per-repository IFC labels across
|
||||
// every matched repository and attaches the result to callResult when IFC
|
||||
// labels are enabled. Visibility is read directly from the search response —
|
||||
// no extra API call. The join math is shared with search_issues via
|
||||
// ifc.LabelSearchIssues: public-only results stay public-untrusted,
|
||||
// mixed-visibility results become private-untrusted, and all-private results
|
||||
// become private-trusted. The
|
||||
// feature-flag check is centralized here (mirroring the attach* helpers in
|
||||
// ifc_labels.go) so the handler can call this unconditionally.
|
||||
func attachSearchRepositoriesIFCLabel(ctx context.Context, deps ToolDependencies, repos []*github.Repository, callResult *mcp.CallToolResult) {
|
||||
if callResult == nil || callResult.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
|
||||
return
|
||||
}
|
||||
|
||||
visibilities := make([]bool, 0, len(repos))
|
||||
for _, repo := range repos {
|
||||
visibilities = append(visibilities, repo.GetPrivate())
|
||||
}
|
||||
|
||||
setIFCLabel(callResult, ifc.LabelSearchIssues(visibilities))
|
||||
}
|
||||
|
||||
// SearchCode creates a tool to search for code across GitHub repositories. It is
|
||||
// the FeatureFlagFieldsParam-enabled variant: it advertises the optional
|
||||
// `fields` parameter and filters each result to the requested subset. Both this
|
||||
// and LegacySearchCode register under the tool name "search_code"; exactly one
|
||||
// is active for any given request thanks to mutually exclusive
|
||||
// FeatureFlagEnable / FeatureFlagDisable annotations.
|
||||
func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool {
|
||||
st := searchCodeTool(t, true)
|
||||
st.FeatureFlagEnable = FeatureFlagFieldsParam
|
||||
return st
|
||||
}
|
||||
|
||||
// LegacySearchCode is the FeatureFlagFieldsParam-disabled variant of
|
||||
// search_code. It exposes the original schema (no `fields` parameter) and never
|
||||
// filters results, so it acts as the kill switch when the flag is off. It owns
|
||||
// the canonical search_code.snap; the flag-enabled variant owns
|
||||
// search_code_ff_<flag>.snap. Delete this function when the flag is removed.
|
||||
func LegacySearchCode(t translations.TranslationHelperFunc) inventory.ServerTool {
|
||||
st := searchCodeTool(t, false)
|
||||
st.FeatureFlagDisable = []string{FeatureFlagFieldsParam}
|
||||
return st
|
||||
}
|
||||
|
||||
// searchCodeTool builds the search_code tool. When includeFields is true the
|
||||
// tool advertises the optional `fields` parameter, filters each result to the
|
||||
// requested subset, and emits fields telemetry. When false it is the original
|
||||
// tool with no fields parameter and no filtering.
|
||||
func searchCodeTool(t translations.TranslationHelperFunc, includeFields bool) inventory.ServerTool {
|
||||
schema := &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"query": {
|
||||
Type: "string",
|
||||
Description: "Search query (GitHub code search REST). Implicit AND between terms; supports `OR`, `NOT`, and `\"quoted phrase\"` for exact match. Qualifiers: `repo:owner/repo`, `org:`, `user:`, `language:`, `path:dir` (prefix match), `filename:exact.ext`, `extension:`, `in:file`, `in:path`, `size:`, `is:archived`, `is:fork`. Max 256 chars. Examples: `WithContext language:go org:github`; `\"package main\" repo:o/r`; `func extension:go path:cmd repo:o/r`; `NOT TODO language:go repo:o/r`.",
|
||||
},
|
||||
"sort": {
|
||||
Type: "string",
|
||||
Description: "Sort field ('indexed' only)",
|
||||
},
|
||||
"order": {
|
||||
Type: "string",
|
||||
Description: "Sort order for results",
|
||||
Enum: []any{"asc", "desc"},
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
}
|
||||
if includeFields {
|
||||
schema.Properties["fields"] = fieldsSchemaProperty(
|
||||
"Subset of fields to return for each code search result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'repository' and 'text_matches' in particular drops the largest per-result data.",
|
||||
codeSearchItemFieldEnum,
|
||||
)
|
||||
}
|
||||
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,
|
||||
},
|
||||
[]scopes.Scope{scopes.Repo},
|
||||
func(ctx context.Context, deps ToolDependencies, _ *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
|
||||
}
|
||||
var fields []string
|
||||
if includeFields {
|
||||
fields, err = OptionalStringArrayParam(args, "fields")
|
||||
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,
|
||||
TextMatch: true,
|
||||
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
|
||||
}
|
||||
|
||||
minimalItems := make([]MinimalCodeResult, 0, len(result.CodeResults))
|
||||
for _, code := range result.CodeResults {
|
||||
item := MinimalCodeResult{
|
||||
Name: code.GetName(),
|
||||
Path: code.GetPath(),
|
||||
SHA: code.GetSHA(),
|
||||
TextMatches: code.TextMatches,
|
||||
}
|
||||
if code.Repository != nil {
|
||||
item.Repository = code.Repository.GetFullName()
|
||||
}
|
||||
minimalItems = append(minimalItems, item)
|
||||
}
|
||||
|
||||
minimalResult := &MinimalCodeSearchResult{
|
||||
TotalCount: result.GetTotal(),
|
||||
IncompleteResults: result.GetIncompleteResults(),
|
||||
Items: minimalItems,
|
||||
}
|
||||
|
||||
filtered := false
|
||||
var payload any = minimalResult
|
||||
if includeFields && len(fields) > 0 {
|
||||
filteredItems, err := filterEachField(minimalItems, fields)
|
||||
if err != nil {
|
||||
return utils.NewToolResultErrorFromErr("failed to filter code search results", err), nil, nil
|
||||
}
|
||||
payload = map[string]any{
|
||||
"total_count": minimalResult.TotalCount,
|
||||
"incomplete_results": minimalResult.IncompleteResults,
|
||||
"items": filteredItems,
|
||||
}
|
||||
filtered = true
|
||||
}
|
||||
|
||||
r, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
|
||||
}
|
||||
|
||||
if includeFields {
|
||||
recordSearchCodeFieldsUsage(ctx, deps, minimalResult, filtered, len(r))
|
||||
}
|
||||
|
||||
callResult := utils.NewToolResultText(string(r))
|
||||
// Code search spans repositories; the IFC label is the conservative
|
||||
// join across every matched repository's visibility, read directly
|
||||
// from the search response.
|
||||
visibilities := make([]bool, 0, len(result.CodeResults))
|
||||
for _, code := range result.CodeResults {
|
||||
if code.Repository != nil {
|
||||
visibilities = append(visibilities, code.Repository.GetPrivate())
|
||||
}
|
||||
}
|
||||
callResult = attachJoinedIFCLabel(ctx, deps, callResult, visibilities, ifc.LabelSearchIssues)
|
||||
return callResult, nil, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// recordSearchCodeFieldsUsage emits fields telemetry for a search_code call.
|
||||
// sentBytes is the size of the payload actually returned.
|
||||
func recordSearchCodeFieldsUsage(ctx context.Context, deps ToolDependencies, full *MinimalCodeSearchResult, filtered bool, sentBytes int) {
|
||||
recordFieldsUsageFor(ctx, deps, "search_code", full, filtered, sentBytes)
|
||||
}
|
||||
|
||||
func userOrOrgHandler(ctx context.Context, accountType string, deps ToolDependencies, 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
|
||||
}
|
||||
callResult := utils.NewToolResultText(string(r))
|
||||
// User and organization search returns public profile information that is
|
||||
// authored by the account holders themselves, so it is public-untrusted.
|
||||
callResult = attachStaticIFCLabel(ctx, deps, callResult, ifc.PublicUntrusted())
|
||||
return callResult, 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,
|
||||
},
|
||||
[]scopes.Scope{scopes.Repo},
|
||||
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
|
||||
return userOrOrgHandler(ctx, "user", deps, args)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
[]scopes.Scope{scopes.ReadOrg},
|
||||
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
|
||||
return userOrOrgHandler(ctx, "org", deps, args)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// SearchCommits creates a tool to search for commits across GitHub repositories.
|
||||
func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool {
|
||||
schema := &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"query": {
|
||||
Type: "string",
|
||||
Description: "Commit search query (GitHub commit search REST). Searches commit messages on the default branch only. Scope the search with `repo:owner/repo`, `org:`, or `user:` (queries without a scope qualifier match across all of GitHub and are usually not what you want). Other qualifiers: `author:`, `committer:`, `author-name:`, `committer-name:`, `author-email:`, `committer-email:`, `author-date:`, `committer-date:` (supports `>`, `<`, `>=`, `<=`, and `YYYY-MM-DD..YYYY-MM-DD` ranges), `merge:true|false`, `hash:`, `tree:`, `parent:`, `is:public`. Examples: `repo:owner/repo fix panic`; `org:github author:defunkt committer-date:>=2024-01-01`; `\"refactor cache\" repo:o/r`; `hash:abc1234 repo:o/r`.",
|
||||
},
|
||||
"sort": {
|
||||
Type: "string",
|
||||
Description: "Sort by author or committer date (defaults to best match)",
|
||||
Enum: []any{"author-date", "committer-date"},
|
||||
},
|
||||
"order": {
|
||||
Type: "string",
|
||||
Description: "Sort order",
|
||||
Enum: []any{"asc", "desc"},
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
}
|
||||
WithPagination(schema)
|
||||
|
||||
return NewTool(
|
||||
ToolsetMetadataRepos,
|
||||
mcp.Tool{
|
||||
Name: "search_commits",
|
||||
Description: t("TOOL_SEARCH_COMMITS_DESCRIPTION", "Search for commits across GitHub repositories using GitHub's commit search syntax. Useful for finding specific changes, authors, or messages across one or many repositories. Searches the default branch only."),
|
||||
Annotations: &mcp.ToolAnnotations{
|
||||
Title: t("TOOL_SEARCH_COMMITS_USER_TITLE", "Search commits"),
|
||||
ReadOnlyHint: true,
|
||||
},
|
||||
InputSchema: schema,
|
||||
},
|
||||
[]scopes.Scope{scopes.Repo},
|
||||
func(ctx context.Context, deps ToolDependencies, _ *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{
|
||||
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.Commits(ctx, query, opts)
|
||||
if err != nil {
|
||||
return ghErrors.NewGitHubAPIErrorResponse(ctx,
|
||||
fmt.Sprintf("failed to search commits 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 commits", resp, body), nil, nil
|
||||
}
|
||||
|
||||
minimalCommits := make([]MinimalCommitSearchItem, 0, len(result.Commits))
|
||||
for _, commit := range result.Commits {
|
||||
minimalCommits = append(minimalCommits, convertCommitResultToMinimalCommit(commit))
|
||||
}
|
||||
|
||||
minimalResult := &MinimalSearchCommitsResult{
|
||||
TotalCount: result.GetTotal(),
|
||||
IncompleteResults: result.GetIncompleteResults(),
|
||||
Items: minimalCommits,
|
||||
}
|
||||
|
||||
r, err := json.Marshal(minimalResult)
|
||||
if err != nil {
|
||||
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
|
||||
}
|
||||
|
||||
callResult := utils.NewToolResultText(string(r))
|
||||
// Commit search spans repositories; the IFC label is the conservative
|
||||
// join across every matched repository's visibility, read directly
|
||||
// from the search response.
|
||||
visibilities := make([]bool, 0, len(result.Commits))
|
||||
for _, commit := range result.Commits {
|
||||
if commit.Repository != nil {
|
||||
visibilities = append(visibilities, commit.Repository.GetPrivate())
|
||||
}
|
||||
}
|
||||
callResult = attachJoinedIFCLabel(ctx, deps, callResult, visibilities, ifc.LabelSearchIssues)
|
||||
return callResult, nil, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user