package github import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "slices" "strconv" "strings" 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/octicons" "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" "github.com/shurcooL/githubv4" ) func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "get_commit", Description: t("TOOL_GET_COMMITS_DESCRIPTION", "Get details for a commit from a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_COMMITS_USER_TITLE", "Get commit details"), ReadOnlyHint: true, }, InputSchema: WithPagination(&jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "sha": { Type: "string", Description: "Commit SHA, branch name, or tag name", }, "detail": { Type: "string", Enum: []any{"none", "stats", "full_patch"}, Description: "Level of detail to include for changed files. \"none\" omits stats and files entirely. \"stats\" (default) includes per-file metadata: filename, status, and lines-of-code counts (additions, deletions, changes), with no patch content. \"full_patch\" additionally includes the unified diff content for each file and can be very large.", Default: json.RawMessage(`"stats"`), }, }, Required: []string{"owner", "repo", "sha"}, }), }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } sha, err := RequiredParam[string](args, "sha") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } detailRaw, err := OptionalParam[string](args, "detail") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } detail, err := parseCommitDetail(detailRaw) 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.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } commit, resp, err := client.Repositories.GetCommit(ctx, owner, repo, sha, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to get commit: %s", sha), resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get commit", resp, body), nil, nil } // Convert to minimal commit minimalCommit := convertToMinimalCommit(commit, detail) r, err := json.Marshal(minimalCommit) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } result := utils.NewToolResultText(string(r)) // Commit content is reachable from the repo's history; in public // repos anyone can land it via a PR (untrusted), in private repos // only collaborators can (trusted). Confidentiality follows repo // visibility. result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents) return result, nil, nil }, ) } // ListCommits creates a tool to get the list of commits of a branch in a GitHub // repository. It is the FeatureFlagFieldsParam-enabled variant: it advertises // the optional `fields` parameter and filters each commit to the requested // subset. Both this and LegacyListCommits register under the tool name // "list_commits"; exactly one is active for any given request thanks to mutually // exclusive FeatureFlagEnable / FeatureFlagDisable annotations. func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { st := listCommitsTool(t, true) st.FeatureFlagEnable = FeatureFlagFieldsParam return st } // LegacyListCommits is the FeatureFlagFieldsParam-disabled variant of // list_commits. 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 list_commits.snap; the flag-enabled variant owns // list_commits_ff_.snap. Delete this function when the flag is removed. func LegacyListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { st := listCommitsTool(t, false) st.FeatureFlagDisable = []string{FeatureFlagFieldsParam} return st } // listCommitsTool builds the list_commits tool. When includeFields is true the // tool advertises the optional `fields` parameter, filters each commit to the // requested subset, and emits fields telemetry. When false it is the original // tool with no fields parameter and no filtering. func listCommitsTool(t translations.TranslationHelperFunc, includeFields bool) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "sha": { Type: "string", Description: "Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA.", }, "author": { Type: "string", Description: "Author username or email address to filter commits by", }, "path": { Type: "string", Description: "Only commits containing this file path will be returned", }, "since": { Type: "string", Description: "Only commits after this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", }, "until": { Type: "string", Description: "Only commits before this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", }, }, Required: []string{"owner", "repo"}, } if includeFields { schema.Properties["fields"] = fieldsSchemaProperty( "Subset of fields to return for each commit. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields, e.g. just 'sha' and 'html_url'.", listCommitsItemFieldEnum, ) } WithPagination(schema) return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "list_commits", Description: t("TOOL_LIST_COMMITS_DESCRIPTION", "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100)."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_COMMITS_USER_TITLE", "List 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) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } sha, err := OptionalParam[string](args, "sha") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } author, err := OptionalParam[string](args, "author") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } path, err := OptionalParam[string](args, "path") 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 } } sinceStr, err := OptionalParam[string](args, "since") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } untilStr, err := OptionalParam[string](args, "until") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } pagination, err := OptionalPaginationParams(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // Set default perPage to 30 if not provided perPage := pagination.PerPage if perPage == 0 { perPage = 30 } opts := &github.CommitsListOptions{ SHA: sha, Path: path, Author: author, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: perPage, }, } if sinceStr != "" { sinceTime, err := parseISOTimestamp(sinceStr) if err != nil { return utils.NewToolResultError(fmt.Sprintf("invalid since timestamp: %s", err)), nil, nil } opts.Since = sinceTime } if untilStr != "" { untilTime, err := parseISOTimestamp(untilStr) if err != nil { return utils.NewToolResultError(fmt.Sprintf("invalid until timestamp: %s", err)), nil, nil } opts.Until = untilTime } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } commits, resp, err := client.Repositories.ListCommits(ctx, owner, repo, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to list commits: %s", sha), resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list commits", resp, body), nil, nil } // Convert to minimal commits minimalCommits := make([]MinimalCommit, len(commits)) for i, commit := range commits { minimalCommits[i] = convertToMinimalCommit(commit, commitDetailNone) } filtered := false var payload any = minimalCommits if includeFields && len(fields) > 0 { filteredCommits, err := filterEachField(minimalCommits, fields) if err != nil { return utils.NewToolResultErrorFromErr("failed to filter commits", err), nil, nil } payload = filteredCommits filtered = true } r, err := json.Marshal(payload) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } if includeFields { recordFieldsUsageFor(ctx, deps, "list_commits", minimalCommits, filtered, len(r)) } result := utils.NewToolResultText(string(r)) // Commit content is reachable from the repo's history; integrity // follows the same public-untrusted / private-trusted rule as file // contents. Confidentiality follows repo visibility. result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents) return result, nil, nil }, ) } // ListBranches creates a tool to list branches in a GitHub repository. func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "list_branches", Description: t("TOOL_LIST_BRANCHES_DESCRIPTION", "List branches in a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_BRANCHES_USER_TITLE", "List branches"), ReadOnlyHint: true, }, InputSchema: WithPagination(&jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, }, Required: []string{"owner", "repo"}, }), }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") 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.BranchListOptions{ ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, }, } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list branches", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list branches", resp, body), nil, nil } // Convert to minimal branches minimalBranches := make([]MinimalBranch, 0, len(branches)) for _, branch := range branches { minimalBranches = append(minimalBranches, convertToMinimalBranch(branch)) } r, err := json.Marshal(minimalBranches) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } result := utils.NewToolResultText(string(r)) // Branches are structural repo metadata that only collaborators // with push access can create, so integrity is trusted. // Confidentiality follows repo visibility. result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) return result, nil, nil }, ) } // CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository. func CreateOrUpdateFile(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "create_or_update_file", Description: t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", `Create or update a single file in a GitHub repository. If updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations. In order to obtain the SHA of original file version before updating, use the following git command: git rev-parse : SHA MUST be provided for existing file updates. `), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_CREATE_OR_UPDATE_FILE_USER_TITLE", "Create or update file"), ReadOnlyHint: false, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner (username or organization)", }, "repo": { Type: "string", Description: "Repository name", }, "path": { Type: "string", Description: "Path where to create/update the file", }, "content": { Type: "string", Description: "Content of the file", }, "message": { Type: "string", Description: "Commit message", }, "branch": { Type: "string", Description: "Branch to create/update the file in", }, "sha": { Type: "string", Description: "The blob SHA of the file being replaced. Required if the file already exists.", }, }, Required: []string{"owner", "repo", "path", "content", "message", "branch"}, }, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } path, err := RequiredParam[string](args, "path") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } content, err := RequiredParam[string](args, "content") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } message, err := RequiredParam[string](args, "message") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } branch, err := RequiredParam[string](args, "branch") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // json.Marshal encodes byte arrays with base64, which is required for the API. contentBytes := []byte(content) // Create the file options opts := &github.RepositoryContentFileOptions{ Message: github.Ptr(message), Content: contentBytes, Branch: github.Ptr(branch), } // If SHA is provided, set it (for updates) sha, err := OptionalParam[string](args, "sha") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } if sha != "" { opts.SHA = github.Ptr(sha) } // Create or update the file client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } path = strings.TrimPrefix(path, "/") // SHA validation using Contents API to fetch current file metadata (blob SHA) getOpts := &github.RepositoryContentGetOptions{Ref: branch} if sha != "" { // User provided SHA - validate it's still current existingFile, dirContent, respCheck, getErr := client.Repositories.GetContents(ctx, owner, repo, path, getOpts) if respCheck != nil { _ = respCheck.Body.Close() } switch { case getErr != nil: // 404 means file doesn't exist - proceed (new file creation) // Any other error (403, 500, network) should be surfaced if respCheck == nil || respCheck.StatusCode != http.StatusNotFound { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to verify file SHA", respCheck, getErr, ), nil, nil } case dirContent != nil: return utils.NewToolResultError(fmt.Sprintf( "Path %s is a directory, not a file. This tool only works with files.", path)), nil, nil case existingFile != nil: currentSHA := existingFile.GetSHA() if currentSHA != sha { return utils.NewToolResultError(fmt.Sprintf( "SHA mismatch: provided SHA %s is stale. Current file SHA is %s. "+ "Pull the latest changes and use git rev-parse %s:%s to get the current SHA.", sha, currentSHA, branch, path)), nil, nil } } } else { // No SHA provided - check if file already exists existingFile, dirContent, respCheck, getErr := client.Repositories.GetContents(ctx, owner, repo, path, getOpts) if respCheck != nil { _ = respCheck.Body.Close() } switch { case getErr != nil: // 404 means file doesn't exist - proceed with creation // Any other error (403, 500, network) should be surfaced if respCheck == nil || respCheck.StatusCode != http.StatusNotFound { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to check if file exists", respCheck, getErr, ), nil, nil } case dirContent != nil: return utils.NewToolResultError(fmt.Sprintf( "Path %s is a directory, not a file. This tool only works with files.", path)), nil, nil case existingFile != nil: // File exists but no SHA was provided - reject to prevent blind overwrites return utils.NewToolResultError(fmt.Sprintf( "File already exists at %s. You must provide the current file's SHA when updating. "+ "Use git rev-parse %s:%s to get the blob SHA, then retry with the sha parameter.", path, branch, path)), nil, nil } // If file not found, no previous SHA needed (new file creation) } fileContent, resp, err := client.Repositories.CreateFile(ctx, owner, repo, path, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create/update file", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 && resp.StatusCode != 201 { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create/update file", resp, body), nil, nil } minimalResponse := convertToMinimalFileContentResponse(fileContent) return MarshalledTextResult(minimalResponse), nil, nil }, ) } // CreateRepository creates a tool to create a new GitHub repository. func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "create_repository", Description: t("TOOL_CREATE_REPOSITORY_DESCRIPTION", "Create a new GitHub repository in your account or specified organization"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_CREATE_REPOSITORY_USER_TITLE", "Create repository"), ReadOnlyHint: false, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "name": { Type: "string", Description: "Repository name", }, "description": { Type: "string", Description: "Repository description", }, "organization": { Type: "string", Description: "Organization to create the repository in (omit to create in your personal account)", }, "private": { Type: "boolean", Description: "Whether the repository should be private. Defaults to true (private) when omitted.", Default: json.RawMessage("true"), }, "autoInit": { Type: "boolean", Description: "Initialize with README", }, }, Required: []string{"name"}, }, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { name, err := RequiredParam[string](args, "name") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } description, err := OptionalParam[string](args, "description") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } organization, err := OptionalParam[string](args, "organization") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } private, err := OptionalBoolParamWithDefault(args, "private", true) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } autoInit, err := OptionalParam[bool](args, "autoInit") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo := &github.Repository{ Name: github.Ptr(name), Description: github.Ptr(description), Private: github.Ptr(private), AutoInit: github.Ptr(autoInit), } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } createdRepo, resp, err := client.Repositories.Create(ctx, organization, repo) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create repository", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create repository", resp, body), nil, nil } // Return minimal response with just essential information minimalResponse := MinimalResponse{ ID: fmt.Sprintf("%d", createdRepo.GetID()), URL: createdRepo.GetHTMLURL(), } r, err := json.Marshal(minimalResponse) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil }, ) } // FetchRepoIsPrivate returns whether a repository is private. It is a thin // wrapper around the GitHub Repositories.Get endpoint provided as a shared // helper for IFC label computation across tools. func FetchRepoIsPrivate(ctx context.Context, client *github.Client, owner, repo string) (bool, error) { r, resp, err := client.Repositories.Get(ctx, owner, repo) if resp != nil { defer func() { _ = resp.Body.Close() }() } if err != nil { return false, err } return r.GetPrivate(), nil } // GetFileContents creates a tool to get the contents of a file or directory from // a GitHub repository. It is the FeatureFlagFieldsParam-enabled variant: it // advertises the optional `fields` parameter and filters directory listings to // the requested subset. Both this and LegacyGetFileContents register under the // tool name "get_file_contents"; exactly one is active for any given request // thanks to mutually exclusive FeatureFlagEnable / FeatureFlagDisable annotations. func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool { st := getFileContentsTool(t, true) st.FeatureFlagEnable = FeatureFlagFieldsParam return st } // LegacyGetFileContents is the FeatureFlagFieldsParam-disabled variant of // get_file_contents. It exposes the original schema (no `fields` parameter) and // never filters directory listings, so it acts as the kill switch when the flag // is off. It owns the canonical get_file_contents.snap; the flag-enabled variant // owns get_file_contents_ff_.snap. Delete this function when the flag is // removed. func LegacyGetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool { st := getFileContentsTool(t, false) st.FeatureFlagDisable = []string{FeatureFlagFieldsParam} return st } // getFileContentsTool builds the get_file_contents tool. When includeFields is // true the tool advertises the optional `fields` parameter, filters directory // listings to the requested subset, and emits fields telemetry. When false it is // the original tool with no fields parameter and no filtering. func getFileContentsTool(t translations.TranslationHelperFunc, includeFields bool) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner (username or organization)", }, "repo": { Type: "string", Description: "Repository name", }, "path": { Type: "string", Description: "Path to file/directory", Default: json.RawMessage(`"/"`), }, "ref": { Type: "string", Description: "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`", }, "sha": { Type: "string", Description: "Accepts optional commit SHA. If specified, it will be used instead of ref", }, }, Required: []string{"owner", "repo"}, } if includeFields { schema.Properties["fields"] = fieldsSchemaProperty( "Subset of fields to return for each entry when the path is a directory. If omitted, all fields are returned. Ignored when the path is a single file. Use this to reduce response size when listing directories and you only need specific fields, e.g. just 'name' and 'type'.", fileContentFieldEnum, ) } return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "get_file_contents", Description: t("TOOL_GET_FILE_CONTENTS_DESCRIPTION", "Get the contents of a file or directory from a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_FILE_CONTENTS_USER_TITLE", "Get file or directory contents"), ReadOnlyHint: true, }, InputSchema: schema, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } path, err := OptionalParam[string](args, "path") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } path = strings.TrimPrefix(path, "/") ref, err := OptionalParam[string](args, "ref") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } originalRef := ref sha, err := OptionalParam[string](args, "sha") 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 } } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultError("failed to get GitHub client"), nil, nil } // attachIFC adds the IFC label to a successful tool result when // IFC labels are enabled. The visibility lookup is performed // lazily on first use and cached because GetFileContents has // many possible return paths and would otherwise re-fetch on // each. If the visibility lookup fails we skip the label rather // than misclassify the result; the failure is not cached so a // later return path can retry. attachIFC := newRepoVisibilityIFCLabeler(ctx, deps, client, owner, repo, ifc.LabelGetFileContents) rawOpts, fallbackUsed, err := resolveGitReference(ctx, client, owner, repo, ref, sha) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to resolve git reference: %s", err)), nil, nil } if rawOpts.SHA != "" { ref = rawOpts.SHA } var fileSHA string opts := &github.RepositoryContentGetOptions{Ref: ref} // Always call GitHub Contents API first to get metadata including SHA and determine if it's a file or directory fileContent, dirContent, respContents, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) if respContents != nil { defer func() { _ = respContents.Body.Close() }() } // The path does not point to a file or directory. // Instead let's try to find it in the Git Tree by matching the end of the path. if err != nil || (fileContent == nil && dirContent == nil) { res, data, err := matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0) return attachIFC(res), data, err } if fileContent != nil && fileContent.SHA != nil { fileSHA = *fileContent.SHA fileSize := fileContent.GetSize() // Build resource URI for the file using URI templates pathParts := strings.Split(path, "/") resourceURI, err := expandRepoResourceURI(owner, repo, sha, ref, pathParts) if err != nil { return utils.NewToolResultError("failed to build resource URI"), nil, nil } // main branch ref passed in ref parameter but it doesn't exist - default branch was used var successNote string if fallbackUsed { successNote = fmt.Sprintf(" Note: the provided ref '%s' does not exist, default branch '%s' was used instead.", originalRef, rawOpts.Ref) } // Empty files (0 bytes) have no content to decode; return // them directly as empty text to avoid errors from // GetContent when the API returns null content with a // base64 encoding field, and to avoid DetectContentType // misclassifying them as binary. if fileSize == 0 { result := &mcp.ResourceContents{ URI: resourceURI, Text: "", MIMEType: "text/plain", } return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil } // For files >= 1MB, return a ResourceLink instead of content const maxContentSize = 1024 * 1024 // 1MB if fileSize >= maxContentSize { size := int64(fileSize) resourceLink := &mcp.ResourceLink{ URI: resourceURI, Name: fileContent.GetName(), Title: fmt.Sprintf("File: %s", path), Size: &size, } return attachIFC(utils.NewToolResultResourceLink( fmt.Sprintf("File %s is too large to display (%d bytes). Use the download URL to fetch the content: %s (SHA: %s)%s", path, fileSize, fileContent.GetDownloadURL(), fileSHA, successNote), resourceLink)), nil, nil } // For files < 1MB, get content directly from Contents API content, err := fileContent.GetContent() if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to decode file content: %s", err)), nil, nil } // Detect content type from the actual content bytes, // mirroring the original approach of using the Content-Type header // from the raw API response. contentBytes := []byte(content) contentType := http.DetectContentType(contentBytes) // Determine if content is text or binary based on detected content type isTextContent := strings.HasPrefix(contentType, "text/") || contentType == "application/json" || contentType == "application/xml" || strings.HasSuffix(contentType, "+json") || strings.HasSuffix(contentType, "+xml") if isTextContent { result := &mcp.ResourceContents{ URI: resourceURI, Text: content, MIMEType: contentType, } return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil } // Binary content - encode as base64 blob blobContent := base64.StdEncoding.EncodeToString(contentBytes) result := &mcp.ResourceContents{ URI: resourceURI, Blob: []byte(blobContent), MIMEType: contentType, } return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil } else if dirContent != nil { // file content or file SHA is nil which means it's a directory filtered := false var payload any = dirContent if includeFields && len(fields) > 0 { filteredEntries, err := filterEachField(dirContent, fields) if err != nil { return utils.NewToolResultErrorFromErr("failed to filter directory contents", err), nil, nil } payload = filteredEntries filtered = true } r, err := json.Marshal(payload) if err != nil { return utils.NewToolResultError("failed to marshal response"), nil, nil } if includeFields { recordDirContentsFieldsUsage(ctx, deps, dirContent, filtered, len(r)) } return attachIFC(utils.NewToolResultText(string(r))), nil, nil } return utils.NewToolResultError("failed to get file contents"), nil, nil }, ) } // recordDirContentsFieldsUsage emits fields telemetry for a get_file_contents // directory listing. sentBytes is the size of the payload actually returned. func recordDirContentsFieldsUsage(ctx context.Context, deps ToolDependencies, full []*github.RepositoryContent, filtered bool, sentBytes int) { recordFieldsUsageFor(ctx, deps, "get_file_contents", full, filtered, sentBytes) } // ForkRepository creates a tool to fork a repository. func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "fork_repository", Description: t("TOOL_FORK_REPOSITORY_DESCRIPTION", "Fork a GitHub repository to your account or specified organization"), Icons: octicons.Icons("repo-forked"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_FORK_REPOSITORY_USER_TITLE", "Fork repository"), ReadOnlyHint: false, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "organization": { Type: "string", Description: "Organization to fork to", }, }, Required: []string{"owner", "repo"}, }, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } org, err := OptionalParam[string](args, "organization") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } opts := &github.RepositoryCreateForkOptions{} if org != "" { opts.Organization = org } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } forkedRepo, resp, err := client.Repositories.CreateFork(ctx, owner, repo, opts) if err != nil { // Check if it's an acceptedError. An acceptedError indicates that the update is in progress, // and it's not a real error. if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) { return utils.NewToolResultText("Fork is in progress"), nil, nil } return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to fork repository", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusAccepted { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to fork repository", resp, body), nil, nil } // Return minimal response with just essential information minimalResponse := MinimalResponse{ ID: fmt.Sprintf("%d", forkedRepo.GetID()), URL: forkedRepo.GetHTMLURL(), } r, err := json.Marshal(minimalResponse) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil }, ) } // DeleteFile creates a tool to delete a file in a GitHub repository. // This tool uses a more roundabout way of deleting a file than just using the client.Repositories.DeleteFile. // This is because REST file deletion endpoint (and client.Repositories.DeleteFile) don't add commit signing to the deletion commit, // unlike how the endpoint backing the create_or_update_files tool does. This appears to be a quirk of the API. // The approach implemented here gets automatic commit signing when used with either the github-actions user or as an app, // both of which suit an LLM well. func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "delete_file", Description: t("TOOL_DELETE_FILE_DESCRIPTION", "Delete a file from a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_DELETE_FILE_USER_TITLE", "Delete file"), ReadOnlyHint: false, DestructiveHint: github.Ptr(true), }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner (username or organization)", }, "repo": { Type: "string", Description: "Repository name", }, "path": { Type: "string", Description: "Path to the file to delete", }, "message": { Type: "string", Description: "Commit message", }, "branch": { Type: "string", Description: "Branch to delete the file from", }, }, Required: []string{"owner", "repo", "path", "message", "branch"}, }, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } path, err := RequiredParam[string](args, "path") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } message, err := RequiredParam[string](args, "message") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } branch, err := RequiredParam[string](args, "branch") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } // Get the reference for the branch ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) if err != nil { return nil, nil, fmt.Errorf("failed to get branch reference: %w", err) } defer func() { _ = resp.Body.Close() }() // Get the commit object that the branch points to baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get base commit", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get commit", resp, body), nil, nil } // Create a tree entry for the file deletion by setting SHA to nil treeEntries := []*github.TreeEntry{ { Path: github.Ptr(path), Mode: github.Ptr("100644"), // Regular file mode Type: github.Ptr("blob"), SHA: nil, // Setting SHA to nil deletes the file }, } // Create a new tree with the deletion newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, treeEntries) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create tree", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create tree", resp, body), nil, nil } // Create a new commit with the new tree commit := github.Commit{ Message: github.Ptr(message), Tree: newTree, Parents: []*github.Commit{{SHA: baseCommit.SHA}}, } newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create commit", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create commit", resp, body), nil, nil } // Update the branch reference to point to the new commit ref.Object.SHA = newCommit.SHA _, resp, err = client.Git.UpdateRef(ctx, owner, repo, *ref.Ref, github.UpdateRef{ SHA: *newCommit.SHA, Force: github.Ptr(false), }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update reference", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to update reference", resp, body), nil, nil } // Create a response similar to what the DeleteFile API would return response := map[string]any{ "commit": newCommit, "content": nil, } r, err := json.Marshal(response) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil }, ) } // CreateBranch creates a tool to create a new branch. func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "create_branch", Description: t("TOOL_CREATE_BRANCH_DESCRIPTION", "Create a new branch in a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_CREATE_BRANCH_USER_TITLE", "Create branch"), ReadOnlyHint: false, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "branch": { Type: "string", Description: "Name for new branch", }, "from_branch": { Type: "string", Description: "Source branch (defaults to repo default)", }, }, Required: []string{"owner", "repo", "branch"}, }, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } branch, err := RequiredParam[string](args, "branch") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } fromBranch, err := OptionalParam[string](args, "from_branch") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } // Get the source branch SHA var ref *github.Reference if fromBranch == "" { // Get default branch if from_branch not specified repository, resp, err := client.Repositories.Get(ctx, owner, repo) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get repository", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() fromBranch = *repository.DefaultBranch } // Get SHA of source branch ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+fromBranch) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get reference", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() // Create new branch newRef := github.CreateRef{ Ref: "refs/heads/" + branch, SHA: *ref.Object.SHA, } createdRef, resp, err := client.Git.CreateRef(ctx, owner, repo, newRef) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create branch", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() r, err := json.Marshal(createdRef) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil }, ) } // PushFiles creates a tool to push multiple files in a single commit to a GitHub repository. func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "push_files", Description: t("TOOL_PUSH_FILES_DESCRIPTION", "Push multiple files to a GitHub repository in a single commit"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_PUSH_FILES_USER_TITLE", "Push files to repository"), ReadOnlyHint: false, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "branch": { Type: "string", Description: "Branch to push to", }, "files": { Type: "array", Description: "Array of file objects to push, each object with path (string) and content (string)", Items: &jsonschema.Schema{ Type: "object", AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, Properties: map[string]*jsonschema.Schema{ "path": { Type: "string", Description: "path to the file", }, "content": { Type: "string", Description: "file content", }, }, Required: []string{"path", "content"}, }, }, "message": { Type: "string", Description: "Commit message", }, }, Required: []string{"owner", "repo", "branch", "files", "message"}, }, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } branch, err := RequiredParam[string](args, "branch") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } message, err := RequiredParam[string](args, "message") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // Parse files parameter - this should be an array of objects with path and content filesObj, ok := args["files"].([]any) if !ok { return utils.NewToolResultError("files parameter must be an array of objects with path and content"), nil, nil } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } // Get the reference for the branch var repositoryIsEmpty bool var branchNotFound bool ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) if err != nil { ghErr, isGhErr := err.(*github.ErrorResponse) if isGhErr { if ghErr.Response.StatusCode == http.StatusConflict && ghErr.Message == "Git Repository is empty." { repositoryIsEmpty = true } else if ghErr.Response.StatusCode == http.StatusNotFound { branchNotFound = true } } if !repositoryIsEmpty && !branchNotFound { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get branch reference", resp, err, ), nil, nil } } // Only close resp if it's not nil and not an error case where resp might be nil if resp != nil && resp.Body != nil { defer func() { _ = resp.Body.Close() }() } var baseCommit *github.Commit if !repositoryIsEmpty { if branchNotFound { ref, err = createReferenceFromDefaultBranch(ctx, client, owner, repo, branch) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to create branch from default: %v", err)), nil, nil } } // Get the commit object that the branch points to baseCommit, resp, err = client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get base commit", resp, err, ), nil, nil } if resp != nil && resp.Body != nil { defer func() { _ = resp.Body.Close() }() } } else { var base *github.Commit // Repository is empty, need to initialize it first ref, base, err = initializeRepository(ctx, client, owner, repo) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to initialize repository: %v", err)), nil, nil } defaultBranch := strings.TrimPrefix(*ref.Ref, "refs/heads/") if branch != defaultBranch { // Create the requested branch from the default branch ref, err = createReferenceFromDefaultBranch(ctx, client, owner, repo, branch) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to create branch from default: %v", err)), nil, nil } } baseCommit = base } // Create tree entries for all files (or remaining files if empty repo) var entries []*github.TreeEntry for _, file := range filesObj { fileMap, ok := file.(map[string]any) if !ok { return utils.NewToolResultError("each file must be an object with path and content"), nil, nil } path, ok := fileMap["path"].(string) if !ok || path == "" { return utils.NewToolResultError("each file must have a path"), nil, nil } content, ok := fileMap["content"].(string) if !ok { return utils.NewToolResultError("each file must have content"), nil, nil } // Create a tree entry for the file entries = append(entries, &github.TreeEntry{ Path: github.Ptr(path), Mode: github.Ptr("100644"), // Regular file mode Type: github.Ptr("blob"), Content: github.Ptr(content), }) } // Create a new tree with the file entries (baseCommit is now guaranteed to exist) newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, entries) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create tree", resp, err, ), nil, nil } if resp != nil && resp.Body != nil { defer func() { _ = resp.Body.Close() }() } // Create a new commit (baseCommit always has a value now) commit := github.Commit{ Message: github.Ptr(message), Tree: newTree, Parents: []*github.Commit{{SHA: baseCommit.SHA}}, } newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create commit", resp, err, ), nil, nil } if resp != nil && resp.Body != nil { defer func() { _ = resp.Body.Close() }() } // Update the reference to point to the new commit ref.Object.SHA = newCommit.SHA updatedRef, resp, err := client.Git.UpdateRef(ctx, owner, repo, *ref.Ref, github.UpdateRef{ SHA: *newCommit.SHA, Force: github.Ptr(false), }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update reference", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() r, err := json.Marshal(updatedRef) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil }, ) } // ListTags creates a tool to list tags in a GitHub repository. func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "list_tags", Description: t("TOOL_LIST_TAGS_DESCRIPTION", "List git tags in a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_TAGS_USER_TITLE", "List tags"), ReadOnlyHint: true, }, InputSchema: WithPagination(&jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, }, Required: []string{"owner", "repo"}, }), }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") 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.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list tags", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list tags", resp, body), nil, nil } minimalTags := make([]MinimalTag, 0, len(tags)) for _, tag := range tags { if tag != nil { minimalTags = append(minimalTags, convertToMinimalTag(tag)) } } r, err := json.Marshal(minimalTags) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } result := utils.NewToolResultText(string(r)) // Tags are structural repo metadata created by collaborators with // push access, so integrity is trusted. Confidentiality follows // repo visibility. result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) return result, nil, nil }, ) } // GetTag creates a tool to get details about a specific tag in a GitHub repository. func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "get_tag", Description: t("TOOL_GET_TAG_DESCRIPTION", "Get details about a specific git tag in a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_TAG_USER_TITLE", "Get tag details"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "tag": { Type: "string", Description: "Tag name", }, }, Required: []string{"owner", "repo", "tag"}, }, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } tag, err := RequiredParam[string](args, "tag") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } // First get the tag reference ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/tags/"+tag) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get tag reference", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get tag reference", resp, body), nil, nil } // Differentiate between lightweight and annotated tags since lightweight ones don't have a fetchable object if ref.Object.GetType() == "commit" { r, err := json.Marshal(ref) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } result := utils.NewToolResultText(string(r)) result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) return result, nil, nil } tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get tag object", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get tag object", resp, body), nil, nil } r, err := json.Marshal(tagObj) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } result := utils.NewToolResultText(string(r)) // An annotated tag object is structural repo metadata created by a // collaborator with push access. Confidentiality follows repo // visibility. result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) return result, nil, nil }, ) } // ListReleases creates a tool to list releases in a GitHub repository. It is the // FeatureFlagFieldsParam-enabled variant: it advertises the optional `fields` // parameter and filters each release to the requested subset. Both this and // LegacyListReleases register under the tool name "list_releases"; exactly one is // active for any given request thanks to mutually exclusive FeatureFlagEnable / // FeatureFlagDisable annotations. func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { st := listReleasesTool(t, true) st.FeatureFlagEnable = FeatureFlagFieldsParam return st } // LegacyListReleases is the FeatureFlagFieldsParam-disabled variant of // list_releases. 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 list_releases.snap; the flag-enabled variant owns // list_releases_ff_.snap. Delete this function when the flag is removed. func LegacyListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { st := listReleasesTool(t, false) st.FeatureFlagDisable = []string{FeatureFlagFieldsParam} return st } // listReleasesTool builds the list_releases tool. When includeFields is true the // tool advertises the optional `fields` parameter, filters each release to the // requested subset, and emits fields telemetry. When false it is the original // tool with no fields parameter and no filtering. func listReleasesTool(t translations.TranslationHelperFunc, includeFields bool) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, }, Required: []string{"owner", "repo"}, } if includeFields { schema.Properties["fields"] = fieldsSchemaProperty( "Subset of fields to return for each release. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' in particular drops the largest per-release data.", listReleasesItemFieldEnum, ) } WithPagination(schema) return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "list_releases", Description: t("TOOL_LIST_RELEASES_DESCRIPTION", "List releases in a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_RELEASES_USER_TITLE", "List releases"), ReadOnlyHint: true, }, InputSchema: schema, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") 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.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } releases, resp, err := client.Repositories.ListReleases(ctx, owner, repo, opts) if err != nil { return nil, nil, fmt.Errorf("failed to list releases: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list releases", resp, body), nil, nil } minimalReleases := make([]MinimalRelease, 0, len(releases)) for _, release := range releases { if release != nil { minimalReleases = append(minimalReleases, convertToMinimalRelease(release)) } } filtered := false var payload any = minimalReleases if includeFields && len(fields) > 0 { filteredReleases, err := filterEachField(minimalReleases, fields) if err != nil { return utils.NewToolResultErrorFromErr("failed to filter releases", err), nil, nil } payload = filteredReleases filtered = true } r, err := json.Marshal(payload) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } if includeFields { recordFieldsUsageFor(ctx, deps, "list_releases", minimalReleases, filtered, len(r)) } result := utils.NewToolResultText(string(r)) // Releases are published by collaborators with push access, so // integrity is trusted. Confidentiality follows repo visibility, // but draft releases are visible only to push-access users and are // not world-readable even on a public repo, so the result is only // public when no returned release is a draft. hasDraft := false for _, mr := range minimalReleases { if mr.Draft { hasDraft = true break } } result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, func(isPrivate bool) ifc.SecurityLabel { return ifc.LabelRelease(isPrivate, hasDraft) }) return result, nil, nil }, ) } // GetLatestRelease creates a tool to get the latest release in a GitHub repository. func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "get_latest_release", Description: t("TOOL_GET_LATEST_RELEASE_DESCRIPTION", "Get the latest release in a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_LATEST_RELEASE_USER_TITLE", "Get latest release"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, }, Required: []string{"owner", "repo"}, }, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } release, resp, err := client.Repositories.GetLatestRelease(ctx, owner, repo) if err != nil { return nil, nil, fmt.Errorf("failed to get latest release: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get latest release", resp, body), nil, nil } r, err := json.Marshal(release) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } result := utils.NewToolResultText(string(r)) // Releases are published by collaborators with push access, so // integrity is trusted. The "latest release" endpoint never returns // a draft, but the draft flag is honored defensively: a draft is // not world-readable even on a public repo. result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, func(isPrivate bool) ifc.SecurityLabel { return ifc.LabelRelease(isPrivate, release.GetDraft()) }) return result, nil, nil }, ) } func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "get_release_by_tag", Description: t("TOOL_GET_RELEASE_BY_TAG_DESCRIPTION", "Get a specific release by its tag name in a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_RELEASE_BY_TAG_USER_TITLE", "Get a release by tag name"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "tag": { Type: "string", Description: "Tag name (e.g., 'v1.0.0')", }, }, Required: []string{"owner", "repo", "tag"}, }, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } tag, err := RequiredParam[string](args, "tag") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } release, resp, err := client.Repositories.GetReleaseByTag(ctx, owner, repo, tag) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to get release by tag: %s", tag), resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get release by tag", resp, body), nil, nil } r, err := json.Marshal(release) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } result := utils.NewToolResultText(string(r)) // Releases are published by collaborators with push access, so // integrity is trusted. A release fetched by tag may be a draft, // which is visible only to push-access users and not world-readable // even on a public repo, so a draft forces private confidentiality. result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, func(isPrivate bool) ifc.SecurityLabel { return ifc.LabelRelease(isPrivate, release.GetDraft()) }) return result, nil, nil }, ) } // ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataStargazers, mcp.Tool{ Name: "list_starred_repositories", Description: t("TOOL_LIST_STARRED_REPOSITORIES_DESCRIPTION", "List starred repositories"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_STARRED_REPOSITORIES_USER_TITLE", "List starred repositories"), ReadOnlyHint: true, }, InputSchema: WithPagination(&jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "username": { Type: "string", Description: "Username to list starred repositories for. Defaults to the authenticated user.", }, "sort": { Type: "string", Description: "How to sort the results. Can be either 'created' (when the repository was starred) or 'updated' (when the repository was last pushed to).", Enum: []any{"created", "updated"}, }, "direction": { Type: "string", Description: "The direction to sort the results by.", Enum: []any{"asc", "desc"}, }, }, }), }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { username, err := OptionalParam[string](args, "username") 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 } direction, err := OptionalParam[string](args, "direction") 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.ActivityListStarredOptions{ ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, }, } if sort != "" { opts.Sort = sort } if direction != "" { opts.Direction = direction } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } var repos []*github.StarredRepository var resp *github.Response if username == "" { // List starred repositories for the authenticated user repos, resp, err = client.Activity.ListStarred(ctx, "", opts) } else { // List starred repositories for a specific user repos, resp, err = client.Activity.ListStarred(ctx, username, opts) } if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to list starred repositories for user '%s'", username), resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list starred repositories", resp, body), nil, nil } // Convert to minimal format minimalRepos := make([]MinimalRepository, 0, len(repos)) for _, starredRepo := range repos { repo := starredRepo.Repository 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") } minimalRepos = append(minimalRepos, minimalRepo) } r, err := json.Marshal(minimalRepos) if err != nil { return nil, nil, fmt.Errorf("failed to marshal starred repositories: %w", err) } result := utils.NewToolResultText(string(r)) // A starred-repository listing exposes repository data across many // repos; reuse the multi-repo join shared with search_repositories // (public-only results stay public-untrusted, mixed-visibility // results become private-untrusted, all-private results become // private-trusted). Visibility is read directly from the response, // so no extra API call is needed. visibilities := make([]bool, 0, len(minimalRepos)) for _, mr := range minimalRepos { visibilities = append(visibilities, mr.Private) } result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelSearchIssues) return result, nil, nil }, ) } // StarRepository creates a tool to star a repository. func StarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataStargazers, mcp.Tool{ Name: "star_repository", Description: t("TOOL_STAR_REPOSITORY_DESCRIPTION", "Star a GitHub repository"), Icons: octicons.Icons("star-fill"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_STAR_REPOSITORY_USER_TITLE", "Star repository"), ReadOnlyHint: false, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, }, Required: []string{"owner", "repo"}, }, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } resp, err := client.Activity.Star(ctx, owner, repo) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to star repository %s/%s", owner, repo), resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 204 { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to star repository", resp, body), nil, nil } return utils.NewToolResultText(fmt.Sprintf("Successfully starred repository %s/%s", owner, repo)), nil, nil }, ) } // UnstarRepository creates a tool to unstar a repository. func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataStargazers, mcp.Tool{ Name: "unstar_repository", Description: t("TOOL_UNSTAR_REPOSITORY_DESCRIPTION", "Unstar a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UNSTAR_REPOSITORY_USER_TITLE", "Unstar repository"), ReadOnlyHint: false, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, }, Required: []string{"owner", "repo"}, }, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } resp, err := client.Activity.Unstar(ctx, owner, repo) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to unstar repository %s/%s", owner, repo), resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 204 { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to unstar repository", resp, body), nil, nil } return utils.NewToolResultText(fmt.Sprintf("Successfully unstarred repository %s/%s", owner, repo)), nil, nil }, ) } // maxBlameRanges caps the number of matching blame ranges considered for one response. const maxBlameRanges = 1000 const blameCursorPrefix = "blame-range:" func encodeBlameCursor(offset int) string { return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s%d", blameCursorPrefix, offset)) } func decodeBlameCursor(cursor string) (int, error) { if cursor == "" { return 0, nil } decoded, err := base64.RawURLEncoding.DecodeString(cursor) if err != nil { return 0, fmt.Errorf("after cursor is invalid") } value := string(decoded) if !strings.HasPrefix(value, blameCursorPrefix) { return 0, fmt.Errorf("after cursor is invalid") } offset, err := strconv.Atoi(strings.TrimPrefix(value, blameCursorPrefix)) if err != nil || offset < 0 { return 0, fmt.Errorf("after cursor is invalid") } return offset, nil } // BlameAuthor describes the author of a commit referenced by a BlameRange. type BlameAuthor struct { Name string `json:"name"` Email string `json:"email"` Login *string `json:"login,omitempty"` URL *string `json:"url,omitempty"` } // BlameCommit holds commit metadata shared by one or more blame ranges. type BlameCommit struct { SHA string `json:"sha"` MessageHeadline string `json:"message_headline"` CommittedDate string `json:"committed_date"` Author BlameAuthor `json:"author"` } // BlameRange is a contiguous run of lines attributed to a single commit. // // Age is the relative position of this range's commit among distinct commits // touching the file (0 = newest), not an absolute time delta. See: // https://docs.github.com/en/graphql/reference/objects#blamerange type BlameRange struct { StartingLine int `json:"starting_line"` EndingLine int `json:"ending_line"` Age int `json:"age"` CommitSHA string `json:"commit_sha"` } // BlameResult is the response payload returned by the get_file_blame tool. // // Commits is keyed by SHA. TotalRanges counts matching ranges before cursor // pagination or truncation. Truncated reports whether maxBlameRanges was hit. type BlameResult struct { Repository string `json:"repository"` Path string `json:"path"` Ref string `json:"ref"` Ranges []BlameRange `json:"ranges"` Commits map[string]BlameCommit `json:"commits"` PageInfo MinimalPageInfo `json:"pageInfo"` TotalRanges int `json:"total_ranges"` Truncated bool `json:"truncated,omitempty"` } // blameCommitFragment is the GraphQL selection for a Commit's blame data. type blameCommitFragment struct { Blame struct { Ranges []struct { StartingLine githubv4.Int EndingLine githubv4.Int Age githubv4.Int Commit struct { OID githubv4.String Message githubv4.String CommittedDate githubv4.DateTime Author struct { Name githubv4.String Email githubv4.String User *struct { Login githubv4.String URL githubv4.String } } } } } `graphql:"blame(path: $path)"` } // validateBlamePath rejects empty, leading-slash, traversal-laden, or // control-character paths before any network call is made. func validateBlamePath(p string) error { if strings.TrimSpace(p) == "" { return fmt.Errorf("path must not be empty") } if strings.HasPrefix(p, "/") { return fmt.Errorf("path must be relative to the repository root (no leading '/')") } if slices.Contains(strings.Split(p, "/"), "..") { return fmt.Errorf("path must not contain '..' segments") } for _, r := range p { if r < 0x20 || r == 0x7f { return fmt.Errorf("path must not contain control characters") } } return nil } func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { st := NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "get_file_blame", Description: t("TOOL_GET_FILE_BLAME_DESCRIPTION", "Get git blame information for a file, showing the commit that last modified each line. "+ "Ranges share commit metadata via the top-level 'commits' map keyed by SHA. "+ "Use 'start_line'/'end_line' to restrict the result to a window of the file, and "+ "'perPage'/'after' to cursor-page through returned ranges. Matching ranges are capped at "+ "1000; when the cap is hit 'truncated' is set to true and 'total_ranges' reports the pre-cap match count.", ), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_FILE_BLAME_USER_TITLE", "Get file blame information"), ReadOnlyHint: true, }, InputSchema: WithCursorPagination(&jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner (username or organization)", }, "repo": { Type: "string", Description: "Repository name", }, "path": { Type: "string", Description: "Path to the file in the repository, relative to the repository root", }, "ref": { Type: "string", Description: "Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (HEAD).", }, "start_line": { Type: "number", Description: "Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window.", Minimum: jsonschema.Ptr(1.0), }, "end_line": { Type: "number", Description: "Optional 1-based ending line of the window of interest. Must be >= start_line when both are provided.", Minimum: jsonschema.Ptr(1.0), }, }, Required: []string{"owner", "repo", "path"}, }), }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } path, err := RequiredParam[string](args, "path") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } if err := validateBlamePath(path); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } ref, err := OptionalParam[string](args, "ref") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } _, hasStartLine := args["start_line"] startLine, err := OptionalIntParam(args, "start_line") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } if hasStartLine && startLine < 1 { return utils.NewToolResultError("start_line must be omitted or >= 1"), nil, nil } _, hasEndLine := args["end_line"] endLine, err := OptionalIntParam(args, "end_line") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } if hasEndLine && endLine < 1 { return utils.NewToolResultError("end_line must be omitted or >= 1"), nil, nil } if hasStartLine && hasEndLine && endLine < startLine { return utils.NewToolResultError("end_line must be >= start_line when both are provided"), nil, nil } if _, hasPage := args["page"]; hasPage { return utils.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil, nil } pagination, err := OptionalCursorPaginationParams(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } if _, hasPerPage := args["perPage"]; hasPerPage { perPage, err := OptionalIntParam(args, "perPage") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } if perPage < 1 || perPage > 100 { return utils.NewToolResultError("perPage must be between 1 and 100 when provided"), nil, nil } pagination.PerPage = perPage } afterOffset, err := decodeBlameCursor(pagination.After) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetGQLClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub GraphQL client: %w", err) } // Default to HEAD and fetch defaultBranchRef.name in the same query // so the response can echo a readable ref. refExpression := ref if refExpression == "" { refExpression = "HEAD" } var blameQuery struct { Repository struct { DefaultBranchRef struct { Name githubv4.String } Object struct { Typename githubv4.String `graphql:"__typename"` Commit blameCommitFragment `graphql:"... on Commit"` // Annotated tag targets are followed one level. Tag-of-tag // chains are not followed and will return an error. Tag struct { Target struct { Typename githubv4.String `graphql:"__typename"` Commit blameCommitFragment `graphql:"... on Commit"` } } `graphql:"... on Tag"` } `graphql:"object(expression: $ref)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "ref": githubv4.String(refExpression), "path": githubv4.String(path), } if err := client.Query(ctx, &blameQuery, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, fmt.Sprintf("failed to get blame for file: %s", path), err, ), nil, nil } // GitHub's Commit.blame field accepts only path, and Blame.ranges is // not a connection, so cursor pagination is applied locally below. // The ref must resolve to a commit, either directly or via an annotated tag. objectTypename := string(blameQuery.Repository.Object.Typename) if objectTypename == "" { return utils.NewToolResultError( fmt.Sprintf("ref %q was not found in %s/%s", refExpression, owner, repo), ), nil, nil } blameCommit := &blameQuery.Repository.Object.Commit if objectTypename == "Tag" { targetTypename := string(blameQuery.Repository.Object.Tag.Target.Typename) if targetTypename != "Commit" { if targetTypename == "" { targetTypename = "unknown" } return utils.NewToolResultError( fmt.Sprintf("ref %q resolved to a tag in %s/%s, but the tag target did not resolve to a commit (resolved to %s)", refExpression, owner, repo, targetTypename), ), nil, nil } blameCommit = &blameQuery.Repository.Object.Tag.Target.Commit } else if objectTypename != "Commit" { return utils.NewToolResultError( fmt.Sprintf("ref %q did not resolve to a commit in %s/%s (resolved to %s)", refExpression, owner, repo, objectTypename), ), nil, nil } // Echo the caller's ref, otherwise prefer the default branch name. responseRef := ref if responseRef == "" { if name := string(blameQuery.Repository.DefaultBranchRef.Name); name != "" { responseRef = name } else { responseRef = refExpression } } rawRanges := blameCommit.Blame.Ranges pageRanges := make([]BlameRange, 0, pagination.PerPage) commits := make(map[string]BlameCommit) totalRanges := 0 truncated := false for _, r := range rawRanges { start := int(r.StartingLine) end := int(r.EndingLine) if startLine > 0 && end < startLine { continue } if endLine > 0 && start > endLine { continue } if startLine > 0 && start < startLine { start = startLine } if endLine > 0 && end > endLine { end = endLine } matchIndex := totalRanges totalRanges++ if matchIndex >= maxBlameRanges { truncated = true continue } if matchIndex < afterOffset || len(pageRanges) >= pagination.PerPage { continue } blameRange := BlameRange{ StartingLine: start, EndingLine: end, Age: int(r.Age), CommitSHA: string(r.Commit.OID), } pageRanges = append(pageRanges, blameRange) sha := string(r.Commit.OID) if _, seen := commits[sha]; seen { continue } headline := string(r.Commit.Message) if idx := strings.IndexByte(headline, '\n'); idx >= 0 { headline = headline[:idx] } headline = strings.TrimRight(headline, " \t\r") bc := BlameCommit{ SHA: sha, MessageHeadline: headline, CommittedDate: r.Commit.CommittedDate.Format("2006-01-02T15:04:05Z"), Author: BlameAuthor{ Name: string(r.Commit.Author.Name), Email: string(r.Commit.Author.Email), }, } if r.Commit.Author.User != nil { login := string(r.Commit.Author.User.Login) url := string(r.Commit.Author.User.URL) bc.Author.Login = &login bc.Author.URL = &url } commits[sha] = bc } cappedRanges := min(totalRanges, maxBlameRanges) consumedRanges := min(afterOffset+len(pageRanges), cappedRanges) pageInfo := MinimalPageInfo{ HasNextPage: consumedRanges < cappedRanges, HasPreviousPage: afterOffset > 0, } if len(pageRanges) > 0 { pageInfo.StartCursor = encodeBlameCursor(afterOffset) pageInfo.EndCursor = encodeBlameCursor(consumedRanges) } result := BlameResult{ Repository: fmt.Sprintf("%s/%s", owner, repo), Path: path, Ref: responseRef, Ranges: pageRanges, Commits: commits, PageInfo: pageInfo, TotalRanges: totalRanges, Truncated: truncated, } if result.Ranges == nil { result.Ranges = []BlameRange{} } payload, err := json.Marshal(result) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(payload)), nil, nil }, ) st.FeatureFlagEnable = FeatureFlagFileBlame return st } // ListRepositoryCollaborators creates a tool to list collaborators of a GitHub repository. func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "list_repository_collaborators", Description: t("TOOL_LIST_REPOSITORY_COLLABORATORS_DESCRIPTION", "List collaborators of a GitHub repository. Results are paginated; the response includes `nextPage`, `prevPage`, `firstPage`, and `lastPage` fields. To get the next page, use the `nextPage` value as the `page` parameter."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_REPOSITORY_COLLABORATORS_USER_TITLE", "List repository collaborators"), ReadOnlyHint: true, }, InputSchema: func() *jsonschema.Schema { schema := WithPagination(&jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "affiliation": { Type: "string", Description: "Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all'", Enum: []any{"outside", "direct", "all"}, }, }, Required: []string{"owner", "repo"}, }) schema.Properties["page"].Description = "Page number for pagination (default 1, min 1)" schema.Properties["perPage"].Description = "Results per page for pagination (default 30, min 1, max 100)" return schema }(), }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } affiliation, err := OptionalParam[string](args, "affiliation") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } pagination, err := OptionalPaginationParams(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } opts := &github.ListCollaboratorsOptions{ Affiliation: affiliation, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, }, } collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list collaborators", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list collaborators", resp, body), nil, nil } result := make([]MinimalCollaborator, 0, len(collaborators)) for _, c := range collaborators { result = append(result, MinimalCollaborator{ Login: c.GetLogin(), ID: c.GetID(), RoleName: c.GetRoleName(), }) } response := map[string]any{ "items": result, "nextPage": resp.NextPage, "prevPage": resp.PrevPage, "firstPage": resp.FirstPage, "lastPage": resp.LastPage, } callResult := MarshalledTextResult(response) // The collaborator roster is GitHub-maintained membership data // (trusted, not attacker-authored). Listing collaborators requires // push access, so the roster is never world-readable — not even on // a public repo — hence always private confidentiality. callResult = attachStaticIFCLabel(ctx, deps, callResult, ifc.LabelCollaboratorRoster()) return callResult, nil, nil }, ) }