Files
github--github-mcp-server/pkg/github/repositories.go
Sam Morrow 6b3c375492 feat: Add Octicon icons to MCP tools, resources, and prompts (#1603)
* Upgrade MCP Go SDK to v1.2.0-pre.1 and add Octicon icons to tools

- Upgrade MCP Go SDK from v1.1.0 to v1.2.0-pre.1 for Icon support
- Add Icon field to ToolsetMetadata for Octicon name assignment
- Add OcticonURL() helper to generate CDN URLs for Octicon SVGs
- Add Icons() method on ToolsetMetadata to generate MCP Icon objects
- Apply icons automatically in RegisterFunc when tool is registered
- Add icons to all 22 toolset metadata constants with appropriate Octicons
- Update server.go to use new Capabilities API (fixes deprecation warnings)

This demonstrates how the toolsets refactor makes adding new features simpler:
icons are defined once in ToolsetMetadata and automatically applied to all
tools in that toolset during registration.

* Update third-party licenses for SDK upgrade

* Address review feedback: enum size validation, mutation fix, tests

- Replace runtime size validation with compile-time enum type (Size with SizeSM=16, SizeLG=24)
- Fix RegisterFunc mutation by making shallow copy of tool before modifying Icons
- Add comprehensive tests for octicons package (URL, Icons, Size constants)
- Add toolsets tests for ToolsetMetadata.Icons(), RegisterFunc mutation prevention,
  and existing icon preservation
- Improve icon choices for better visual semantics:
  - actions: play → workflow (more specific to GitHub Actions)
  - secret_protection: key → shield-lock (better represents protection)
  - gists: code → logo-gist (dedicated gist icon exists)

* Add GitHub mark icon to server metadata

Add the mark-github octicon to the server's Implementation struct
so that MCP clients can display the GitHub logo for this server.
The icon is provided in both 16x16 and 24x24 SVG sizes.

* Fix rebase conflicts: use Registry methods and NullTranslationHelper

- Remove duplicate old toolsets functions (AvailableToolsets, GetValidToolsetIDs, GetDefaultToolsetIDs)
- Use Registry.AvailableToolsets() and Registry.HasToolset() instead
- Replace stubTranslator with translations.NullTranslationHelper
- Use new SDK Capabilities struct instead of deprecated HasTools/HasResources/HasPrompts
- Add icon-related tests to registry_test.go

* Use embedded data URIs for Octicon icons

- Embed SVG icons using go:embed for offline use and faster loading
- Convert icons to base64 data URIs at runtime
- Fall back to CDN URL for non-embedded icons
- Add test to verify all toolset icons are properly embedded
- 44 SVG files (22 icons × 2 sizes) totaling ~27KB

* Convert icons from SVG to PNG for MCP client compatibility

MCP clients don't support SVG data URIs, so convert all embedded icons
to PNG format using rsvg-convert.

Changes:
- Convert all 44 SVG icons to PNG format
- Add 8 new icons: copilot, git-merge, repo-forked, star-fill
- Update octicons.go to use PNG MIME type
- Add script/fetch-icons for easy icon management
- Update tests and toolsnaps for PNG format

* Add mark-github icon for server metadata

* Add light/dark theme icons for tools, resources, and prompts

- Switch from size-based (16/24px) to theme-based (light/dark) icons
- Use only 16x16 icons for smaller bundle size
- Generate white (inverted) icons for dark theme backgrounds
- Add icons to resources and prompts (auto-applied from toolset metadata)
- Add 'file' icon for repository content resources
- Update fetch-icons script to generate both theme variants

* Use 24px icons with SVG fill modification for themes

- Switch from 16px to 24px icons for better visibility
- Use SVG fill attribute (#24292f for light, #ffffff for dark) instead
  of ImageMagick color inversion for cleaner theme variants
- Remove ImageMagick dependency from fetch-icons script

* Add specific icons for each repository resource type

- repository_content: repo icon
- repository_content_branch: git-branch icon
- repository_content_commit: git-commit icon (new)
- repository_content_tag: tag icon
- repository_content_pr: git-pull-request icon

Resources now have explicit icons set rather than relying on toolset fallback.

* fix: restore Icon fields to toolset metadata and add icons to docs

- Add Icon field to all ToolsetMetadata definitions (lost during rebase conflict resolution)
- Update doc generator to include Octicon icons in toolsets table
- Update doc generator to include icons in tool section headers
- Use Primer Octicons CDN for GitHub markdown compatibility

* feat: add icons to individual tools in documentation

* fix: use repo-local icons with picture element for GitHub theme support

- Reference icons from pkg/octicons/icons/ instead of external CDN
- Use picture element with prefers-color-scheme for light/dark mode
- GitHub markdown renderer will display these correctly

* fix: remove redundant icons from individual tools

Icons are kept on section headers and toolsets table only - having the same
icon on every tool within a section was visually noisy and redundant.

* Add icons to remote server toolsets documentation

* Fix icon paths for docs/remote-server.md

* Add remote-only toolsets with auto-generated documentation and icons guide

- Add ToolsetMetadataCopilot, ToolsetMetadataCopilotSpaces, ToolsetMetadataSupportSearch
- Add RemoteOnlyToolsets() function to return remote-only toolset metadata
- Update doc generator to auto-generate remote-only toolsets table with icons
- Create docs/toolsets-and-icons.md explaining how to add icons to toolsets
- Add link to icons guide in CONTRIBUTING.md

* Add icon validation tests and single source of truth for required icons

- Add pkg/octicons/required_icons.txt as single source of truth for icons
- Add RequiredIcons() function to read the required icons list
- Update script/fetch-icons to read from required_icons.txt
- Update octicons_test.go to use RequiredIcons() instead of hardcoded list
- Add pkg/github/toolset_icons_test.go with:
  - TestAllToolsetIconsExist: validates all toolset icons are embedded
  - TestToolsetMetadataHasIcons: ensures all toolsets have icons set
- Add 'book' icon for SupportSearch toolset
- Update docs/toolsets-and-icons.md with fetch-icons and CI validation docs

* fix: remove unused icon parameter from writeToolDoc

- Remove unused 'icon' parameter from writeToolDoc function signature
- Fix whitespace inconsistency in octicons_test.go
- Fixes lint failure: unused-parameter revive error

* fix: combine icon with name column in remote docs for proper table rendering

- Move icon from separate column to Name column with <br> separator
- Keep <picture> element for light/dark theme support
- Remove empty icon column that was collapsing to zero width
- Remove unused octiconSimpleImg function
2025-12-17 17:31:13 +01:00

2229 lines
73 KiB
Go

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/inventory"
"github.com/github/github-mcp-server/pkg/octicons"
"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(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",
},
"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"},
}),
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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, 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
}
},
)
}
// ListCommits creates a tool to get commits of a branch in a repository.
func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool {
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: 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"},
}),
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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, 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
}
},
)
}
// 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"},
}),
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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)
}
return utils.NewToolResultText(string(r)), 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 ls-tree HEAD <path to file>
If the SHA is not provided, the tool will attempt to acquire it by fetching the current file contents from the repository, which may lead to rewriting latest committed changes if the file has changed since last retrieval.
`),
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: []string{"owner", "repo", "path", "content", "message", "branch"},
},
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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 conditional HEAD request (efficient - no body transfer)
var previousSHA string
contentURL := fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, url.PathEscape(path))
if branch != "" {
contentURL += "?ref=" + url.QueryEscape(branch)
}
if sha != "" {
// User provided SHA - validate it's still current
req, err := client.NewRequest("HEAD", contentURL, nil)
if err == nil {
req.Header.Set("If-None-Match", fmt.Sprintf(`"%s"`, sha))
resp, _ := client.Do(ctx, req, nil)
if resp != nil {
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusNotModified:
// SHA matches current - proceed
opts.SHA = github.Ptr(sha)
case http.StatusOK:
// SHA is stale - reject with current SHA so user can check diff
currentSHA := strings.Trim(resp.Header.Get("ETag"), `"`)
return utils.NewToolResultError(fmt.Sprintf(
"SHA mismatch: provided SHA %s is stale. Current file SHA is %s. "+
"Use get_file_contents or compare commits to review changes before updating.",
sha, currentSHA)), nil, nil
case http.StatusNotFound:
// File doesn't exist - this is a create, ignore provided SHA
}
}
}
} else {
// No SHA provided - check if file exists to warn about blind update
req, err := client.NewRequest("HEAD", contentURL, nil)
if err == nil {
resp, _ := client.Do(ctx, req, nil)
if resp != nil {
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
previousSHA = strings.Trim(resp.Header.Get("ETag"), `"`)
}
// 404 = new file, no previous SHA needed
}
}
}
if previousSHA != "" {
opts.SHA = github.Ptr(previousSHA)
}
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
}
r, err := json.Marshal(fileContent)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
// Warn if file was updated without SHA validation (blind update)
if sha == "" && previousSHA != "" {
return utils.NewToolResultText(fmt.Sprintf(
"Warning: File updated without SHA validation. Previous file SHA was %s. "+
`Verify no unintended changes were overwritten:
1. Extract the SHA of the local version using git ls-tree HEAD %s.
2. Compare with the previous SHA above.
3. Revert changes if shas do not match.
%s`,
previousSHA, path, string(r))), nil, nil
}
return utils.NewToolResultText(string(r)), 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 repo should be private",
},
"autoInit": {
Type: "boolean",
Description: "Initialize with README",
},
},
Required: []string{"name"},
},
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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
}
},
)
}
// GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository.
func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool {
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: &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"},
},
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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
}
sha, err := OptionalParam[string](args, "sha")
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
}
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 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) {
return matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0)
}
if fileContent != nil && fileContent.SHA != nil {
fileSHA = *fileContent.SHA
rawClient, err := deps.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
}
// Raw API call failed
return matchFiles(ctx, client, owner, repo, ref, path, rawOpts, resp.StatusCode)
} else if dirContent != nil {
// file content or file SHA is nil which means it's a directory
r, err := json.Marshal(dirContent)
if err != nil {
return utils.NewToolResultError("failed to marshal response"), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
}
return utils.NewToolResultError("failed to get file contents"), nil, nil
}
},
)
}
// 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"},
},
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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"},
},
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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]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
}
},
)
}
// 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"},
},
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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",
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"},
},
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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 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
}
},
)
}
// 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"},
}),
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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
}
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
}
},
)
}
// 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"},
},
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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
}
// 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 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)
}
return utils.NewToolResultText(string(r)), nil, nil
}
},
)
}
// ListReleases creates a tool to list releases in a GitHub repository.
func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool {
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: 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"},
}),
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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
}
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
}
},
)
}
// 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"},
},
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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)
}
return utils.NewToolResultText(string(r)), 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"},
},
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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)
}
return utils.NewToolResultText(string(r)), nil, nil
}
},
)
}
// matchFiles searches for files in the Git tree that match the given path.
// It's used when GetContents fails or returns unexpected results.
func matchFiles(ctx context.Context, client *github.Client, owner, repo, ref, path string, rawOpts *raw.ContentOpts, rawAPIResponseCode int) (*mcp.CallToolResult, any, error) {
// Step 1: Get Git Tree recursively
tree, response, err := client.Git.GetTree(ctx, owner, repo, ref, true)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get git tree",
response,
err,
), nil, nil
}
defer func() { _ = response.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
}
if rawAPIResponseCode > 0 {
return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil, nil
}
return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s).", string(resolvedRefs), string(matchingFilesJSON))), 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
}
// 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/<ref>"). If that
// returns a 404 Not Found error, it then attempts to resolve it as a tag
// ("refs/tags/<ref>").
//
// 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(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"},
},
},
}),
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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)
}
return utils.NewToolResultText(string(r)), 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"},
},
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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"},
},
},
func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] {
return 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 := 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
}
},
)
}