package github import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := 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", }, "include_diff": { Type: "boolean", Description: "Whether to include file diffs and stats in the response. Default is true.", Default: json.RawMessage(`true`), }, }, Required: []string{"owner", "repo", "sha"}, }), } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 } includeDiff, err := OptionalBoolParamWithDefault(args, "include_diff", true) 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 := 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 utils.NewToolResultError(fmt.Sprintf("failed to get commit: %s", string(body))), nil, nil } // Convert to minimal commit minimalCommit := convertToMinimalCommit(commit, includeDiff) r, err := json.Marshal(minimalCommit) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil }) return tool, handler } // ListCommits creates a tool to get commits of a branch in a repository. func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := 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: 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 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", }, }, Required: []string{"owner", "repo"}, }), } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 } 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, Author: author, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: perPage, }, } client, err := 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 utils.NewToolResultError(fmt.Sprintf("failed to list commits: %s", string(body))), nil, nil } // Convert to minimal commits minimalCommits := make([]MinimalCommit, len(commits)) for i, commit := range commits { minimalCommits[i] = convertToMinimalCommit(commit, false) } r, err := json.Marshal(minimalCommits) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil }) return tool, handler } // ListBranches creates a tool to list branches in a GitHub repository. func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := 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"}, }), } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 := 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 utils.NewToolResultError(fmt.Sprintf("failed to list branches: %s", string(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) } return utils.NewToolResultText(string(r)), nil, nil }) return tool, handler } // CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository. func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := 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 must 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."), 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: "Required if updating an existing file. The blob SHA of the file being replaced.", }, }, Required: []string{"owner", "repo", "path", "content", "message", "branch"}, }, } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 := getClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } 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 utils.NewToolResultError(fmt.Sprintf("failed to create/update file: %s", string(body))), nil, nil } r, err := json.Marshal(fileContent) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil }) return tool, handler } // CreateRepository creates a tool to create a new GitHub repository. func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := 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 repo should be private", }, "autoInit": { Type: "boolean", Description: "Initialize with README", }, }, Required: []string{"name"}, }, } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 := OptionalParam[bool](args, "private") 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 := 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 utils.NewToolResultError(fmt.Sprintf("failed to create repository: %s", string(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 }) return tool, handler } // GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository. func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := 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: &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 (directories must end with a slash '/')", 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"}, }, } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 } ref, err := OptionalParam[string](args, "ref") 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 } client, err := getClient(ctx) if err != nil { return utils.NewToolResultError("failed to get GitHub client"), nil, nil } rawOpts, 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 the path is (most likely) not to be a directory, we will // first try to get the raw content from the GitHub raw content API. var rawAPIResponseCode int if path != "" && !strings.HasSuffix(path, "/") { // First, get file info from Contents API to retrieve SHA var fileSHA string opts := &github.RepositoryContentGetOptions{Ref: ref} fileContent, _, respContents, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) if respContents != nil { defer func() { _ = respContents.Body.Close() }() } if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get file SHA", respContents, err, ), nil, nil } if fileContent == nil || fileContent.SHA == nil { return utils.NewToolResultError("file content SHA is nil, if a directory was requested, path parameters should end with a trailing slash '/'"), nil, nil } fileSHA = *fileContent.SHA rawClient, err := getRawClient(ctx) if err != nil { return utils.NewToolResultError("failed to get GitHub raw content client"), nil, nil } resp, err := rawClient.GetRawContent(ctx, owner, repo, path, rawOpts) if err != nil { return utils.NewToolResultError("failed to get raw repository content"), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusOK { // If the raw content is found, return it directly body, err := io.ReadAll(resp.Body) if err != nil { return utils.NewToolResultError("failed to read response body"), nil, nil } contentType := resp.Header.Get("Content-Type") var resourceURI string switch { case sha != "": resourceURI, err = url.JoinPath("repo://", owner, repo, "sha", sha, "contents", path) if err != nil { return nil, nil, fmt.Errorf("failed to create resource URI: %w", err) } case ref != "": resourceURI, err = url.JoinPath("repo://", owner, repo, ref, "contents", path) if err != nil { return nil, nil, fmt.Errorf("failed to create resource URI: %w", err) } default: resourceURI, err = url.JoinPath("repo://", owner, repo, "contents", path) if err != nil { return nil, nil, fmt.Errorf("failed to create resource URI: %w", err) } } // Determine if content is text or binary 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: string(body), MIMEType: contentType, } // Include SHA in the result metadata if fileSHA != "" { return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)", fileSHA), result), nil, nil } return utils.NewToolResultResource("successfully downloaded text file", result), nil, nil } result := &mcp.ResourceContents{ URI: resourceURI, Blob: body, MIMEType: contentType, } // Include SHA in the result metadata if fileSHA != "" { return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)", fileSHA), result), nil, nil } return utils.NewToolResultResource("successfully downloaded binary file", result), nil, nil } rawAPIResponseCode = resp.StatusCode } if rawOpts.SHA != "" { ref = rawOpts.SHA } if strings.HasSuffix(path, "/") { opts := &github.RepositoryContentGetOptions{Ref: ref} _, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) if err == nil && resp.StatusCode == http.StatusOK { defer func() { _ = resp.Body.Close() }() r, err := json.Marshal(dirContent) if err != nil { return utils.NewToolResultError("failed to marshal response"), nil, nil } return utils.NewToolResultText(string(r)), nil, nil } } // 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. // Step 1: Get Git Tree recursively tree, resp, err := client.Git.GetTree(ctx, owner, repo, ref, true) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get git tree", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() // Step 2: Filter tree for matching paths const maxMatchingFiles = 3 matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles) if len(matchingFiles) > 0 { matchingFilesJSON, err := json.Marshal(matchingFiles) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil, nil } resolvedRefs, err := json.Marshal(rawOpts) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil, nil } return utils.NewToolResultError(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the raw content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil, nil } return utils.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), nil, nil }) return tool, handler } // ForkRepository creates a tool to fork a repository. func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := mcp.Tool{ Name: "fork_repository", Description: t("TOOL_FORK_REPOSITORY_DESCRIPTION", "Fork a GitHub repository to your account or specified organization"), 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"}, }, } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 := 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 utils.NewToolResultError(fmt.Sprintf("failed to fork repository: %s", string(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 }) return tool, handler } // 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(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := 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"}, }, } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 := 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 utils.NewToolResultError(fmt.Sprintf("failed to get commit: %s", string(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 utils.NewToolResultError(fmt.Sprintf("failed to create tree: %s", string(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 utils.NewToolResultError(fmt.Sprintf("failed to create commit: %s", string(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 utils.NewToolResultError(fmt.Sprintf("failed to update reference: %s", string(body))), nil, nil } // Create a response similar to what the DeleteFile API would return response := map[string]interface{}{ "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 }) return tool, handler } // CreateBranch creates a tool to create a new branch. func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := 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"}, }, } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 := 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 }) return tool, handler } // PushFiles creates a tool to push multiple files in a single commit to a GitHub repository. func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := 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", 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"}, }, } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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"].([]interface{}) if !ok { return utils.NewToolResultError("files parameter must be an array of objects with path and content"), nil, nil } client, err := 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 ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get branch reference", resp, err, ), nil, nil } 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() }() // Create tree entries for all files var entries []*github.TreeEntry for _, file := range filesObj { fileMap, ok := file.(map[string]interface{}) 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 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 } defer func() { _ = resp.Body.Close() }() // Create a new commit 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() }() // 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 }) return tool, handler } // ListTags creates a tool to list tags in a GitHub repository. func ListTags(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := 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"}, }), } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 := 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 utils.NewToolResultError(fmt.Sprintf("failed to list tags: %s", string(body))), nil, nil } r, err := json.Marshal(tags) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil }) return tool, handler } // GetTag creates a tool to get details about a specific tag in a GitHub repository. func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := 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"}, }, } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 := 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 utils.NewToolResultError(fmt.Sprintf("failed to get tag reference: %s", string(body))), nil, nil } // Then get the tag object 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 utils.NewToolResultError(fmt.Sprintf("failed to get tag object: %s", string(body))), nil, nil } r, err := json.Marshal(tagObj) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil }) return tool, handler } // ListReleases creates a tool to list releases in a GitHub repository. func ListReleases(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := 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: 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"}, }), } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 := 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 utils.NewToolResultError(fmt.Sprintf("failed to list releases: %s", string(body))), nil, nil } r, err := json.Marshal(releases) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil }) return tool, handler } // GetLatestRelease creates a tool to get the latest release in a GitHub repository. func GetLatestRelease(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := 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"}, }, } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 := 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 utils.NewToolResultError(fmt.Sprintf("failed to get latest release: %s", string(body))), nil, nil } r, err := json.Marshal(release) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil }) return tool, handler } func GetReleaseByTag(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := 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"}, }, } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 := 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 utils.NewToolResultError(fmt.Sprintf("failed to get release by tag: %s", string(body))), nil, nil } r, err := json.Marshal(release) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil }) return tool, handler } // filterPaths filters the entries in a GitHub tree to find paths that // match the given suffix. // maxResults limits the number of results returned to first maxResults entries, // a maxResults of -1 means no limit. // It returns a slice of strings containing the matching paths. // Directories are returned with a trailing slash. func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []string { // Remove trailing slash for matching purposes, but flag whether we // only want directories. dirOnly := false if strings.HasSuffix(path, "/") { dirOnly = true path = strings.TrimSuffix(path, "/") } matchedPaths := []string{} for _, entry := range entries { if len(matchedPaths) == maxResults { break // Limit the number of results to maxResults } if dirOnly && entry.GetType() != "tree" { continue // Skip non-directory entries if dirOnly is true } entryPath := entry.GetPath() if entryPath == "" { continue // Skip empty paths } if strings.HasSuffix(entryPath, path) { if entry.GetType() == "tree" { entryPath += "/" // Return directories with a trailing slash } matchedPaths = append(matchedPaths, entryPath) } } return matchedPaths } // resolveGitReference takes a user-provided ref and sha and resolves them into a // definitive commit SHA and its corresponding fully-qualified reference. // // The resolution logic follows a clear priority: // // 1. If a specific commit `sha` is provided, it takes precedence and is used directly, // and all reference resolution is skipped. // // 2. If no `sha` is provided, the function resolves the `ref` // string into a fully-qualified format (e.g., "refs/heads/main") by trying // the following steps in order: // a). **Empty Ref:** If `ref` is empty, the repository's default branch is used. // b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully // qualified and used as-is. // c). **Partially-Qualified:** If `ref` starts with "heads/" or "tags/", it is // prefixed with "refs/" to make it fully-qualified. // d). **Short Name:** Otherwise, the `ref` is treated as a short name. The function // first attempts to resolve it as a branch ("refs/heads/"). If that // returns a 404 Not Found error, it then attempts to resolve it as a tag // ("refs/tags/"). // // 3. **Final Lookup:** Once a fully-qualified ref is determined, a final API call // is made to fetch that reference's definitive commit SHA. // // Any unexpected (non-404) errors during the resolution process are returned // immediately. All API errors are logged with rich context to aid diagnostics. func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, error) { // 1) If SHA explicitly provided, it's the highest priority. if sha != "" { return &raw.ContentOpts{Ref: "", SHA: sha}, nil } originalRef := ref // Keep original ref for clearer error messages down the line. // 2) If no SHA is provided, we try to resolve the ref into a fully-qualified format. var reference *github.Reference var resp *github.Response var err error switch { case originalRef == "": // 2a) If ref is empty, determine the default branch. repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo) if err != nil { _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) return nil, fmt.Errorf("failed to get repository info: %w", err) } ref = fmt.Sprintf("refs/heads/%s", repoInfo.GetDefaultBranch()) case strings.HasPrefix(originalRef, "refs/"): // 2b) Already fully qualified. The reference will be fetched at the end. case strings.HasPrefix(originalRef, "heads/") || strings.HasPrefix(originalRef, "tags/"): // 2c) Partially qualified. Make it fully qualified. ref = "refs/" + originalRef default: // 2d) It's a short name, so we try to resolve it to either a branch or a tag. branchRef := "refs/heads/" + originalRef reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, branchRef) if err == nil { ref = branchRef // It's a branch. } else { // The branch lookup failed. Check if it was a 404 Not Found error. ghErr, isGhErr := err.(*github.ErrorResponse) if isGhErr && ghErr.Response.StatusCode == http.StatusNotFound { tagRef := "refs/tags/" + originalRef reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, tagRef) if err == nil { ref = tagRef // It's a tag. } else { // The tag lookup also failed. Check if it was a 404 Not Found error. ghErr2, isGhErr2 := err.(*github.ErrorResponse) if isGhErr2 && ghErr2.Response.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef) } // The tag lookup failed for a different reason. _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (tag)", resp, err) return nil, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err) } } else { // The branch lookup failed for a different reason. _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (branch)", resp, err) return nil, fmt.Errorf("failed to get reference for branch '%s': %w", originalRef, err) } } } if reference == nil { reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, ref) if err != nil { _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) return nil, fmt.Errorf("failed to get final reference for %q: %w", ref, err) } } sha = reference.GetObject().GetSHA() return &raw.ContentOpts{Ref: ref, SHA: sha}, nil } // ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. func ListStarredRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := 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"}, }, }, }), } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 := 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 utils.NewToolResultError(fmt.Sprintf("failed to list starred repositories: %s", string(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) } return utils.NewToolResultText(string(r)), nil, nil }) return tool, handler } // StarRepository creates a tool to star a repository. func StarRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := mcp.Tool{ Name: "star_repository", Description: t("TOOL_STAR_REPOSITORY_DESCRIPTION", "Star a GitHub repository"), 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"}, }, } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 := 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 utils.NewToolResultError(fmt.Sprintf("failed to star repository: %s", string(body))), nil, nil } return utils.NewToolResultText(fmt.Sprintf("Successfully starred repository %s/%s", owner, repo)), nil, nil }) return tool, handler } // UnstarRepository creates a tool to unstar a repository. func UnstarRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := 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"}, }, } handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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 := 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 utils.NewToolResultError(fmt.Sprintf("failed to unstar repository: %s", string(body))), nil, nil } return utils.NewToolResultText(fmt.Sprintf("Successfully unstarred repository %s/%s", owner, repo)), nil, nil }) return tool, handler }