diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml index 83d2c30be..5226e93f8 100644 --- a/.github/workflows/code-scanning.yml +++ b/.github/workflows/code-scanning.yml @@ -35,7 +35,7 @@ jobs: runner: '["ubuntu-22.04"]' steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Initialize CodeQL uses: github/codeql-action/init@v3 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index cd2d923cb..015a91e30 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Install the cosign tool except on PR # https://github.com/sigstore/cosign-installer @@ -63,7 +63,7 @@ jobs: # https://github.com/docker/metadata-action - name: Extract Docker metadata id: meta - uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index c28c528b2..fee936a67 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Go uses: actions/setup-go@v5 diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index e3ef25022..d845479b0 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Go uses: actions/setup-go@v5 diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 263607ee1..e5c4acbec 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Go uses: actions/setup-go@v5 diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index 50f34ff60..5cc732c8f 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Go uses: actions/setup-go@v5 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b40193e72..f3000096b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,7 +13,7 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-go@v5 with: go-version: stable diff --git a/README.md b/README.md index a6e740e66..46eef3b4b 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,6 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block > **Note:** Each MCP host application needs to configure a GitHub App or OAuth App to support remote access via OAuth. Any host application that supports remote MCP servers should support the remote GitHub server with PAT authentication. Configuration details and support levels vary by host. Make sure to refer to the host application's documentation for more info. -> ⚠️ **Public Preview Status:** The **remote** GitHub MCP Server is currently in Public Preview. During preview, access may be gated depending on authentication type and surface: -> - OAuth: Subject to GitHub Copilot Editor Preview Policy until GA -> - PAT: Controlled via your organization's PAT policies -> - MCP Servers in Copilot policy: Enables/disables access to all MCP servers in VS Code, with other Copilot editors migrating to this policy in the coming months. - ### Configuration See [Remote Server Documentation](/docs/remote-server.md) on how to pass additional configuration settings to the remote GitHub MCP Server. @@ -242,6 +237,7 @@ For other MCP host applications, please refer to our installation guides: - **[GitHub Copilot in other IDEs](/docs/installation-guides/install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot - **[Claude Code & Claude Desktop](docs/installation-guides/install-claude.md)** - Installation guide for Claude Code and Claude Desktop - **[Cursor](docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE +- **[Google Gemini CLI](docs/installation-guides/install-gemini-cli.md)** - Installation guide for Google Gemini CLI - **[Windsurf](docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE For a complete overview of all installation options, see our **[Installation Guides Index](docs/installation-guides)**. @@ -705,11 +701,6 @@ The following sets of tools are available (all are on by default): - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) -- **get_pull_request_comments** - Get pull request comments - - `owner`: Repository owner (string, required) - - `pullNumber`: Pull request number (number, required) - - `repo`: Repository name (string, required) - - **get_pull_request_diff** - Get pull request diff - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) @@ -722,6 +713,11 @@ The following sets of tools are available (all are on by default): - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) +- **get_pull_request_review_comments** - Get pull request review comments + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number (number, required) + - `repo`: Repository name (string, required) + - **get_pull_request_reviews** - Get pull request reviews - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) @@ -815,6 +811,7 @@ The following sets of tools are available (all are on by default): - `autoInit`: Initialize with README (boolean, optional) - `description`: Repository description (string, optional) - `name`: Repository name (string, required) + - `organization`: Organization to create the repository in (omit to create in your personal account) (string, optional) - `private`: Whether repo should be private (boolean, optional) - **delete_file** - Delete file @@ -830,6 +827,7 @@ The following sets of tools are available (all are on by default): - `repo`: Repository name (string, required) - **get_commit** - Get commit details + - `include_diff`: Whether to include file diffs and stats in the response. Default is true. (boolean, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -898,6 +896,7 @@ The following sets of tools are available (all are on by default): - `sort`: Sort field ('indexed' only) (string, optional) - **search_repositories** - Search repositories + - `minimal_output`: Return minimal repository information (default: true). When false, returns full GitHub API repository objects. (boolean, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `query`: Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering. (string, required) diff --git a/docs/installation-guides/README.md b/docs/installation-guides/README.md index f55cc6bef..13af2f7aa 100644 --- a/docs/installation-guides/README.md +++ b/docs/installation-guides/README.md @@ -6,6 +6,7 @@ This directory contains detailed installation instructions for the GitHub MCP Se - **[GitHub Copilot in other IDEs](install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot - **[Claude Applications](install-claude.md)** - Installation guide for Claude Web, Claude Desktop and Claude Code CLI - **[Cursor](install-cursor.md)** - Installation guide for Cursor IDE +- **[Google Gemini CLI](install-gemini-cli.md)** - Installation guide for Google Gemini CLI - **[Windsurf](install-windsurf.md)** - Installation guide for Windsurf IDE ## Support by Host Application @@ -19,6 +20,7 @@ This directory contains detailed installation instructions for the GitHub MCP Se | Claude Code | ✅ | ✅ PAT + ❌ No OAuth| GitHub MCP Server binary or remote URL, GitHub PAT | Easy | | Claude Desktop | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Moderate | | Cursor | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | +| Google Gemini CLI | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Windsurf | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Copilot in Xcode | ✅ | ✅ PAT + ❌ No OAuth | Local: Docker or Go build, GitHub PAT
Remote: Copilot for Xcode latest version | Easy | | Copilot in Eclipse | ✅ | ✅ PAT + ❌ No OAuth | Local: Docker or Go build, GitHub PAT
Remote: TBD | Easy | diff --git a/docs/installation-guides/install-gemini-cli.md b/docs/installation-guides/install-gemini-cli.md new file mode 100644 index 000000000..21abc8653 --- /dev/null +++ b/docs/installation-guides/install-gemini-cli.md @@ -0,0 +1,156 @@ +# Install GitHub MCP Server in Google Gemini CLI + +## Prerequisites + +1. Google Gemini CLI installed (see [official Gemini CLI documentation](https://github.com/google-gemini/gemini-cli)) +2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes +3. For local installation: [Docker](https://www.docker.com/) installed and running + +
+Storing Your PAT Securely +
+ +For security, avoid hardcoding your token. Create or update `~/.gemini/.env` (where `~` is your home or project directory) with your PAT: + +```bash +# ~/.gemini/.env +GITHUB_PAT=your_token_here +``` + +
+ +## GitHub MCP Server Configuration + +MCP servers for Gemini CLI are configured in its settings JSON under an `mcpServers` key. + +- **Global configuration**: `~/.gemini/settings.json` where `~` is your home directory +- **Project-specific**: `.gemini/settings.json` in your project directory + +After securely storing your PAT, you can add the GitHub MCP server configuration to your settings file using one of the methods below. You may need to restart the Gemini CLI for changes to take effect. + +> **Note:** For the most up-to-date configuration options, see the [main README.md](../../README.md). + +### Method 1: Remote Server (Recommended) + +The simplest way is to use GitHub's hosted MCP server: + +```json +// ~/.gemini/settings.json +{ + "mcpServers": { + "github": { + "httpUrl": "https://api.githubcopilot.com/mcp/", + "trust": true, + "headers": { + "Authorization": "Bearer $GITHUB_PAT" + } + } + } +} +``` + +### Method 2: Local Docker + +With docker running, you can run the GitHub MCP server in a container: + +```json +// ~/.gemini/settings.json +{ + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_PAT" + } + } + } +} +``` + +### Method 3: Binary + +You can download the latest binary release from the [GitHub releases page](https://github.com/github/github-mcp-server/releases) or build it from source by running `go build -o github-mcp-server ./cmd/github-mcp-server`. + +Then, replacing `/path/to/binary` with the actual path to your binary, configure Gemini CLI with: + +```json +// ~/.gemini/settings.json +{ + "mcpServers": { + "github": { + "command": "/path/to/binary", + "args": ["stdio"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_PAT" + } + } + } +} +``` + +## Verification + +To verify that the GitHub MCP server has been configured, start Gemini CLI in your terminal with `gemini`, then: + +1. **Check MCP server status**: + + ``` + /mcp list + ``` + + ``` + ℹConfigured MCP servers: + + 🟢 github - Ready (96 tools, 2 prompts) + Tools: + - github__add_comment_to_pending_review + - github__add_issue_comment + - github__add_sub_issue + ... + ``` + +2. **Test with a prompt** + ``` + List my GitHub repositories + ``` + +## Troubleshooting + +### Local Server Issues + +- **Docker errors**: Ensure Docker Desktop is running + ```bash + docker --version + ``` +- **Image pull failures**: Try `docker logout ghcr.io` then retry +- **Docker not found**: Install Docker Desktop and ensure it's running + +### Authentication Issues + +- **Invalid PAT**: Verify your GitHub PAT has correct scopes: + - `repo` - Repository operations + - `read:packages` - Docker image access (if using Docker) +- **Token expired**: Generate a new GitHub PAT + +### Configuration Issues + +- **Invalid JSON**: Validate your configuration: + ```bash + cat ~/.gemini/settings.json | jq . + ``` +- **MCP connection issues**: Check logs for connection errors: + ```bash + gemini --debug "test command" + ``` + +## References + +- Gemini CLI Docs > [MCP Configuration Structure](https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html#configuration-structure) diff --git a/go.mod b/go.mod index b566e6c40..1f215fdc0 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/migueleliasweb/go-github-mock v1.3.0 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 ) require ( diff --git a/go.sum b/go.sum index 24377c8aa..72730b820 100644 --- a/go.sum +++ b/go.sum @@ -87,8 +87,8 @@ github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqj github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= diff --git a/pkg/github/__toolsnaps__/create_repository.snap b/pkg/github/__toolsnaps__/create_repository.snap index aaba75f3c..6ed2dbf41 100644 --- a/pkg/github/__toolsnaps__/create_repository.snap +++ b/pkg/github/__toolsnaps__/create_repository.snap @@ -3,7 +3,7 @@ "title": "Create repository", "readOnlyHint": false }, - "description": "Create a new GitHub repository in your account", + "description": "Create a new GitHub repository in your account or specified organization", "inputSchema": { "properties": { "autoInit": { @@ -18,6 +18,10 @@ "description": "Repository name", "type": "string" }, + "organization": { + "description": "Organization to create the repository in (omit to create in your personal account)", + "type": "string" + }, "private": { "description": "Whether repo should be private", "type": "boolean" diff --git a/pkg/github/__toolsnaps__/get_commit.snap b/pkg/github/__toolsnaps__/get_commit.snap index af0038110..1c2ecc9a3 100644 --- a/pkg/github/__toolsnaps__/get_commit.snap +++ b/pkg/github/__toolsnaps__/get_commit.snap @@ -6,6 +6,11 @@ "description": "Get details for a commit from a GitHub repository", "inputSchema": { "properties": { + "include_diff": { + "default": true, + "description": "Whether to include file diffs and stats in the response. Default is true.", + "type": "boolean" + }, "owner": { "description": "Repository owner", "type": "string" diff --git a/pkg/github/__toolsnaps__/get_pull_request_comments.snap b/pkg/github/__toolsnaps__/get_pull_request_review_comments.snap similarity index 60% rename from pkg/github/__toolsnaps__/get_pull_request_comments.snap rename to pkg/github/__toolsnaps__/get_pull_request_review_comments.snap index 6699f6d97..92996fec2 100644 --- a/pkg/github/__toolsnaps__/get_pull_request_comments.snap +++ b/pkg/github/__toolsnaps__/get_pull_request_review_comments.snap @@ -1,9 +1,9 @@ { "annotations": { - "title": "Get pull request comments", + "title": "Get pull request review comments", "readOnlyHint": true }, - "description": "Get comments for a specific pull request.", + "description": "Get pull request review comments. They are comments made on a portion of the unified diff during a pull request review. These are different from commit comments and issue comments in a pull request.", "inputSchema": { "properties": { "owner": { @@ -26,5 +26,5 @@ ], "type": "object" }, - "name": "get_pull_request_comments" + "name": "get_pull_request_review_comments" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_repositories.snap b/pkg/github/__toolsnaps__/search_repositories.snap index d283a2cc0..f350c8e2b 100644 --- a/pkg/github/__toolsnaps__/search_repositories.snap +++ b/pkg/github/__toolsnaps__/search_repositories.snap @@ -6,6 +6,11 @@ "description": "Find GitHub repositories by name, description, readme, topics, or other metadata. Perfect for discovering projects, finding examples, or locating specific repositories across GitHub.", "inputSchema": { "properties": { + "minimal_output": { + "default": true, + "description": "Return minimal repository information (default: true). When false, returns full GitHub API repository objects.", + "type": "boolean" + }, "page": { "description": "Page number for pagination (min 1)", "minimum": 1, diff --git a/pkg/github/gists.go b/pkg/github/gists.go index fce34f6a8..3f1645f3e 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -165,7 +165,11 @@ func CreateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (to return mcp.NewToolResultError(fmt.Sprintf("failed to create gist: %s", string(body))), nil } - r, err := json.Marshal(createdGist) + minimalResponse := MinimalResponse{ + URL: createdGist.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -249,7 +253,11 @@ func UpdateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (to return mcp.NewToolResultError(fmt.Sprintf("failed to update gist: %s", string(body))), nil } - r, err := json.Marshal(updatedGist) + minimalResponse := MinimalResponse{ + URL: updatedGist.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } diff --git a/pkg/github/gists_test.go b/pkg/github/gists_test.go index 49d63a252..9b8b4eb6e 100644 --- a/pkg/github/gists_test.go +++ b/pkg/github/gists_test.go @@ -321,23 +321,12 @@ func Test_CreateGist(t *testing.T) { // Parse the result and get the text content textContent := getTextResult(t, result) - // Unmarshal and verify the result - var gist *github.Gist + // Unmarshal and verify the minimal result + var gist MinimalResponse err = json.Unmarshal([]byte(textContent.Text), &gist) require.NoError(t, err) - assert.Equal(t, *tc.expectedGist.ID, *gist.ID) - assert.Equal(t, *tc.expectedGist.Description, *gist.Description) - assert.Equal(t, *tc.expectedGist.HTMLURL, *gist.HTMLURL) - assert.Equal(t, *tc.expectedGist.Public, *gist.Public) - - // Verify file content - for filename, expectedFile := range tc.expectedGist.Files { - actualFile, exists := gist.Files[filename] - assert.True(t, exists) - assert.Equal(t, *expectedFile.Filename, *actualFile.Filename) - assert.Equal(t, *expectedFile.Content, *actualFile.Content) - } + assert.Equal(t, tc.expectedGist.GetHTMLURL(), gist.URL) }) } } @@ -486,22 +475,12 @@ func Test_UpdateGist(t *testing.T) { // Parse the result and get the text content textContent := getTextResult(t, result) - // Unmarshal and verify the result - var gist *github.Gist - err = json.Unmarshal([]byte(textContent.Text), &gist) + // Unmarshal and verify the minimal result + var updateResp MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &updateResp) require.NoError(t, err) - assert.Equal(t, *tc.expectedGist.ID, *gist.ID) - assert.Equal(t, *tc.expectedGist.Description, *gist.Description) - assert.Equal(t, *tc.expectedGist.HTMLURL, *gist.HTMLURL) - - // Verify file content - for filename, expectedFile := range tc.expectedGist.Files { - actualFile, exists := gist.Files[filename] - assert.True(t, exists) - assert.Equal(t, *expectedFile.Filename, *actualFile.Filename) - assert.Equal(t, *expectedFile.Content, *actualFile.Content) - } + assert.Equal(t, tc.expectedGist.GetHTMLURL(), updateResp.URL) }) } } diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 89375ae90..01ce7b42e 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -872,7 +872,12 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t return mcp.NewToolResultError(fmt.Sprintf("failed to create issue: %s", string(body))), nil } - r, err := json.Marshal(issue) + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + URL: issue.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -1242,7 +1247,12 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t return mcp.NewToolResultError(fmt.Sprintf("failed to update issue: %s", string(body))), nil } - r, err := json.Marshal(updatedIssue) + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + URL: updatedIssue.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 7c4983c64..5a0d409a6 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -712,39 +712,12 @@ func Test_CreateIssue(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedIssue github.Issue + // Unmarshal and verify the minimal result + var returnedIssue MinimalResponse err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) require.NoError(t, err) - assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) - assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) - assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) - assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) - - if tc.expectedIssue.Body != nil { - assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) - } - - if tc.expectedIssue.Type != nil { - assert.Equal(t, *tc.expectedIssue.Type.Name, *returnedIssue.Type.Name) - } - - // Check assignees if expected - if len(tc.expectedIssue.Assignees) > 0 { - assert.Equal(t, len(tc.expectedIssue.Assignees), len(returnedIssue.Assignees)) - for i, assignee := range returnedIssue.Assignees { - assert.Equal(t, *tc.expectedIssue.Assignees[i].Login, *assignee.Login) - } - } - - // Check labels if expected - if len(tc.expectedIssue.Labels) > 0 { - assert.Equal(t, len(tc.expectedIssue.Labels), len(returnedIssue.Labels)) - for i, label := range returnedIssue.Labels { - assert.Equal(t, *tc.expectedIssue.Labels[i].Name, *label.Name) - } - } + assert.Equal(t, tc.expectedIssue.GetHTMLURL(), returnedIssue.URL) }) } } @@ -1233,45 +1206,12 @@ func Test_UpdateIssue(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedIssue github.Issue - err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + // Unmarshal and verify the minimal result + var updateResp MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &updateResp) require.NoError(t, err) - assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) - assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) - assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) - assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) - - if tc.expectedIssue.Body != nil { - assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) - } - - if tc.expectedIssue.Type != nil { - assert.Equal(t, *tc.expectedIssue.Type.Name, *returnedIssue.Type.Name) - } - - // Check assignees if expected - if len(tc.expectedIssue.Assignees) > 0 { - assert.Len(t, returnedIssue.Assignees, len(tc.expectedIssue.Assignees)) - for i, assignee := range returnedIssue.Assignees { - assert.Equal(t, *tc.expectedIssue.Assignees[i].Login, *assignee.Login) - } - } - - // Check labels if expected - if len(tc.expectedIssue.Labels) > 0 { - assert.Len(t, returnedIssue.Labels, len(tc.expectedIssue.Labels)) - for i, label := range returnedIssue.Labels { - assert.Equal(t, *tc.expectedIssue.Labels[i].Name, *label.Name) - } - } - - // Check milestone if expected - if tc.expectedIssue.Milestone != nil { - assert.NotNil(t, returnedIssue.Milestone) - assert.Equal(t, *tc.expectedIssue.Milestone.Number, *returnedIssue.Milestone.Number) - } + assert.Equal(t, tc.expectedIssue.GetHTMLURL(), updateResp.URL) }) } } diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go new file mode 100644 index 000000000..0c3c220aa --- /dev/null +++ b/pkg/github/minimal_types.go @@ -0,0 +1,204 @@ +package github + +import "github.com/google/go-github/v74/github" + +// MinimalUser is the output type for user and organization search results. +type MinimalUser struct { + Login string `json:"login"` + ID int64 `json:"id,omitempty"` + ProfileURL string `json:"profile_url,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Details *UserDetails `json:"details,omitempty"` // Optional field for additional user details +} + +// MinimalSearchUsersResult is the trimmed output type for user search results. +type MinimalSearchUsersResult struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []MinimalUser `json:"items"` +} + +// MinimalRepository is the trimmed output type for repository objects to reduce verbosity. +type MinimalRepository struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description,omitempty"` + HTMLURL string `json:"html_url"` + Language string `json:"language,omitempty"` + Stars int `json:"stargazers_count"` + Forks int `json:"forks_count"` + OpenIssues int `json:"open_issues_count"` + UpdatedAt string `json:"updated_at,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + Topics []string `json:"topics,omitempty"` + Private bool `json:"private"` + Fork bool `json:"fork"` + Archived bool `json:"archived"` + DefaultBranch string `json:"default_branch,omitempty"` +} + +// MinimalSearchRepositoriesResult is the trimmed output type for repository search results. +type MinimalSearchRepositoriesResult struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []MinimalRepository `json:"items"` +} + +// MinimalCommitAuthor represents commit author information. +type MinimalCommitAuthor struct { + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + Date string `json:"date,omitempty"` +} + +// MinimalCommitInfo represents core commit information. +type MinimalCommitInfo struct { + Message string `json:"message"` + Author *MinimalCommitAuthor `json:"author,omitempty"` + Committer *MinimalCommitAuthor `json:"committer,omitempty"` +} + +// MinimalCommitStats represents commit statistics. +type MinimalCommitStats struct { + Additions int `json:"additions,omitempty"` + Deletions int `json:"deletions,omitempty"` + Total int `json:"total,omitempty"` +} + +// MinimalCommitFile represents a file changed in a commit. +type MinimalCommitFile struct { + Filename string `json:"filename"` + Status string `json:"status,omitempty"` + Additions int `json:"additions,omitempty"` + Deletions int `json:"deletions,omitempty"` + Changes int `json:"changes,omitempty"` +} + +// MinimalCommit is the trimmed output type for commit objects. +type MinimalCommit struct { + SHA string `json:"sha"` + HTMLURL string `json:"html_url"` + Commit *MinimalCommitInfo `json:"commit,omitempty"` + Author *MinimalUser `json:"author,omitempty"` + Committer *MinimalUser `json:"committer,omitempty"` + Stats *MinimalCommitStats `json:"stats,omitempty"` + Files []MinimalCommitFile `json:"files,omitempty"` +} + +// MinimalRelease is the trimmed output type for release objects. +type MinimalRelease struct { + ID int64 `json:"id"` + TagName string `json:"tag_name"` + Name string `json:"name,omitempty"` + Body string `json:"body,omitempty"` + HTMLURL string `json:"html_url"` + PublishedAt string `json:"published_at,omitempty"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + Author *MinimalUser `json:"author,omitempty"` +} + +// MinimalBranch is the trimmed output type for branch objects. +type MinimalBranch struct { + Name string `json:"name"` + SHA string `json:"sha"` + Protected bool `json:"protected"` +} + +// MinimalResponse represents a minimal response for all CRUD operations. +// Success is implicit in the HTTP response status, and all other information +// can be derived from the URL or fetched separately if needed. +type MinimalResponse struct { + URL string `json:"url"` +} + +// Helper functions + +// convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit +func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit { + minimalCommit := MinimalCommit{ + SHA: commit.GetSHA(), + HTMLURL: commit.GetHTMLURL(), + } + + if commit.Commit != nil { + minimalCommit.Commit = &MinimalCommitInfo{ + Message: commit.Commit.GetMessage(), + } + + if commit.Commit.Author != nil { + minimalCommit.Commit.Author = &MinimalCommitAuthor{ + Name: commit.Commit.Author.GetName(), + Email: commit.Commit.Author.GetEmail(), + } + if commit.Commit.Author.Date != nil { + minimalCommit.Commit.Author.Date = commit.Commit.Author.Date.Format("2006-01-02T15:04:05Z") + } + } + + if commit.Commit.Committer != nil { + minimalCommit.Commit.Committer = &MinimalCommitAuthor{ + Name: commit.Commit.Committer.GetName(), + Email: commit.Commit.Committer.GetEmail(), + } + if commit.Commit.Committer.Date != nil { + minimalCommit.Commit.Committer.Date = commit.Commit.Committer.Date.Format("2006-01-02T15:04:05Z") + } + } + } + + if commit.Author != nil { + minimalCommit.Author = &MinimalUser{ + Login: commit.Author.GetLogin(), + ID: commit.Author.GetID(), + ProfileURL: commit.Author.GetHTMLURL(), + AvatarURL: commit.Author.GetAvatarURL(), + } + } + + if commit.Committer != nil { + minimalCommit.Committer = &MinimalUser{ + Login: commit.Committer.GetLogin(), + ID: commit.Committer.GetID(), + ProfileURL: commit.Committer.GetHTMLURL(), + AvatarURL: commit.Committer.GetAvatarURL(), + } + } + + // Only include stats and files if includeDiffs is true + if includeDiffs { + if commit.Stats != nil { + minimalCommit.Stats = &MinimalCommitStats{ + Additions: commit.Stats.GetAdditions(), + Deletions: commit.Stats.GetDeletions(), + Total: commit.Stats.GetTotal(), + } + } + + if len(commit.Files) > 0 { + minimalCommit.Files = make([]MinimalCommitFile, 0, len(commit.Files)) + for _, file := range commit.Files { + minimalFile := MinimalCommitFile{ + Filename: file.GetFilename(), + Status: file.GetStatus(), + Additions: file.GetAdditions(), + Deletions: file.GetDeletions(), + Changes: file.GetChanges(), + } + minimalCommit.Files = append(minimalCommit.Files, minimalFile) + } + } + } + + return minimalCommit +} + +// convertToMinimalBranch converts a GitHub API Branch to MinimalBranch +func convertToMinimalBranch(branch *github.Branch) MinimalBranch { + return MinimalBranch{ + Name: branch.GetName(), + SHA: branch.GetCommit().GetSHA(), + Protected: branch.GetProtected(), + } +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 63c5594d3..b2e4e9290 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -193,7 +193,12 @@ func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu return mcp.NewToolResultError(fmt.Sprintf("failed to create pull request: %s", string(body))), nil } - r, err := json.Marshal(pr) + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + URL: pr.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -464,7 +469,12 @@ func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t tra } }() - r, err := json.Marshal(finalPR) + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + URL: finalPR.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal response: %v", err)), nil } @@ -965,12 +975,12 @@ func UpdatePullRequestBranch(getClient GetClientFn, t translations.TranslationHe } } -// GetPullRequestComments creates a tool to get the review comments on a pull request. -func GetPullRequestComments(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - return mcp.NewTool("get_pull_request_comments", - mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_COMMENTS_DESCRIPTION", "Get comments for a specific pull request.")), +// GetPullRequestReviewComments creates a tool to get the review comments on a pull request. +func GetPullRequestReviewComments(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("get_pull_request_review_comments", + mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_REVIEW_COMMENTS_DESCRIPTION", "Get pull request review comments. They are comments made on a portion of the unified diff during a pull request review. These are different from commit comments and issue comments in a pull request.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_PULL_REQUEST_COMMENTS_USER_TITLE", "Get pull request comments"), + Title: t("TOOL_GET_PULL_REQUEST_REVIEW_COMMENTS_USER_TITLE", "Get pull request review comments"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", @@ -1013,7 +1023,7 @@ func GetPullRequestComments(getClient GetClientFn, t translations.TranslationHel comments, resp, err := client.PullRequests.ListComments(ctx, owner, repo, pullNumber, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get pull request comments", + "failed to get pull request review comments", resp, err, ), nil @@ -1025,7 +1035,7 @@ func GetPullRequestComments(getClient GetClientFn, t translations.TranslationHel if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } - return mcp.NewToolResultError(fmt.Sprintf("failed to get pull request comments: %s", string(body))), nil + return mcp.NewToolResultError(fmt.Sprintf("failed to get pull request review comments: %s", string(body))), nil } r, err := json.Marshal(comments) diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index ed6921477..18fc8d87d 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -381,47 +381,11 @@ func Test_UpdatePullRequest(t *testing.T) { // Parse the result and get the text content textContent := getTextResult(t, result) - // Unmarshal and verify the successful result - var returnedPR github.PullRequest - err = json.Unmarshal([]byte(textContent.Text), &returnedPR) + // Unmarshal and verify the minimal result + var updateResp MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &updateResp) require.NoError(t, err) - assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number) - if tc.expectedPR.Title != nil { - assert.Equal(t, *tc.expectedPR.Title, *returnedPR.Title) - } - if tc.expectedPR.Body != nil { - assert.Equal(t, *tc.expectedPR.Body, *returnedPR.Body) - } - if tc.expectedPR.State != nil { - assert.Equal(t, *tc.expectedPR.State, *returnedPR.State) - } - if tc.expectedPR.Base != nil && tc.expectedPR.Base.Ref != nil { - assert.NotNil(t, returnedPR.Base) - assert.Equal(t, *tc.expectedPR.Base.Ref, *returnedPR.Base.Ref) - } - if tc.expectedPR.MaintainerCanModify != nil { - assert.Equal(t, *tc.expectedPR.MaintainerCanModify, *returnedPR.MaintainerCanModify) - } - - // Check reviewers if they exist in the expected PR - if len(tc.expectedPR.RequestedReviewers) > 0 { - assert.NotNil(t, returnedPR.RequestedReviewers) - assert.Equal(t, len(tc.expectedPR.RequestedReviewers), len(returnedPR.RequestedReviewers)) - - // Create maps of reviewer logins for easy comparison - expectedReviewers := make(map[string]bool) - for _, reviewer := range tc.expectedPR.RequestedReviewers { - expectedReviewers[*reviewer.Login] = true - } - - actualReviewers := make(map[string]bool) - for _, reviewer := range returnedPR.RequestedReviewers { - actualReviewers[*reviewer.Login] = true - } - - // Compare the maps - assert.Equal(t, expectedReviewers, actualReviewers) - } + assert.Equal(t, tc.expectedPR.GetHTMLURL(), updateResp.URL) }) } } @@ -599,11 +563,11 @@ func Test_UpdatePullRequest_Draft(t *testing.T) { textContent := getTextResult(t, result) - // Unmarshal and verify the successful result - var returnedPR github.PullRequest - err = json.Unmarshal([]byte(textContent.Text), &returnedPR) + // Unmarshal and verify the minimal result + var updateResp MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &updateResp) require.NoError(t, err) - assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number) + assert.Equal(t, tc.expectedPR.GetHTMLURL(), updateResp.URL) }) } } @@ -1591,10 +1555,10 @@ func Test_UpdatePullRequestBranch(t *testing.T) { func Test_GetPullRequestComments(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := GetPullRequestComments(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := GetPullRequestReviewComments(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "get_pull_request_comments", tool.Name) + assert.Equal(t, "get_pull_request_review_comments", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") @@ -1672,7 +1636,7 @@ func Test_GetPullRequestComments(t *testing.T) { "pullNumber": float64(999), }, expectError: true, - expectedErrMsg: "failed to get pull request comments", + expectedErrMsg: "failed to get pull request review comments", }, } @@ -1680,7 +1644,7 @@ func Test_GetPullRequestComments(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := GetPullRequestComments(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := GetPullRequestReviewComments(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1988,18 +1952,11 @@ func Test_CreatePullRequest(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedPR github.PullRequest + // Unmarshal and verify the minimal result + var returnedPR MinimalResponse err = json.Unmarshal([]byte(textContent.Text), &returnedPR) require.NoError(t, err) - assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number) - assert.Equal(t, *tc.expectedPR.Title, *returnedPR.Title) - assert.Equal(t, *tc.expectedPR.State, *returnedPR.State) - assert.Equal(t, *tc.expectedPR.HTMLURL, *returnedPR.HTMLURL) - assert.Equal(t, *tc.expectedPR.Head.SHA, *returnedPR.Head.SHA) - assert.Equal(t, *tc.expectedPR.Base.Ref, *returnedPR.Base.Ref) - assert.Equal(t, *tc.expectedPR.Body, *returnedPR.Body) - assert.Equal(t, *tc.expectedPR.User.Login, *returnedPR.User.Login) + assert.Equal(t, tc.expectedPR.GetHTMLURL(), returnedPR.URL) }) } } diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index de2c6d01f..cef227ba5 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -37,6 +37,10 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too mcp.Required(), mcp.Description("Commit SHA, branch name, or tag name"), ), + mcp.WithBoolean("include_diff", + mcp.Description("Whether to include file diffs and stats in the response. Default is true."), + mcp.DefaultBool(true), + ), WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -52,6 +56,10 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too if err != nil { return mcp.NewToolResultError(err.Error()), nil } + includeDiff, err := OptionalBoolParamWithDefault(request, "include_diff", true) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } pagination, err := OptionalPaginationParams(request) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -84,7 +92,10 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too return mcp.NewToolResultError(fmt.Sprintf("failed to get commit: %s", string(body))), nil } - r, err := json.Marshal(commit) + // Convert to minimal commit + minimalCommit := convertToMinimalCommit(commit, includeDiff) + + r, err := json.Marshal(minimalCommit) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -174,7 +185,13 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t return mcp.NewToolResultError(fmt.Sprintf("failed to list commits: %s", string(body))), nil } - r, err := json.Marshal(commits) + // 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, fmt.Errorf("failed to marshal response: %w", err) } @@ -245,7 +262,13 @@ func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) ( return mcp.NewToolResultError(fmt.Sprintf("failed to list branches: %s", string(body))), nil } - r, err := json.Marshal(branches) + // 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, fmt.Errorf("failed to marshal response: %w", err) } @@ -370,7 +393,7 @@ func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperF // CreateRepository creates a tool to create a new GitHub repository. func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("create_repository", - mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_DESCRIPTION", "Create a new GitHub repository in your account")), + mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_DESCRIPTION", "Create a new GitHub repository in your account or specified organization")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_CREATE_REPOSITORY_USER_TITLE", "Create repository"), ReadOnlyHint: ToBoolPtr(false), @@ -382,6 +405,9 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun mcp.WithString("description", mcp.Description("Repository description"), ), + mcp.WithString("organization", + mcp.Description("Organization to create the repository in (omit to create in your personal account)"), + ), mcp.WithBoolean("private", mcp.Description("Whether repo should be private"), ), @@ -398,6 +424,10 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } + organization, err := OptionalParam[string](request, "organization") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } private, err := OptionalParam[bool](request, "private") if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -418,7 +448,7 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun if err != nil { return nil, fmt.Errorf("failed to get GitHub client: %w", err) } - createdRepo, resp, err := client.Repositories.Create(ctx, "", repo) + createdRepo, resp, err := client.Repositories.Create(ctx, organization, repo) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create repository", @@ -436,7 +466,12 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun return mcp.NewToolResultError(fmt.Sprintf("failed to create repository: %s", string(body))), nil } - r, err := json.Marshal(createdRepo) + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + URL: createdRepo.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -707,7 +742,12 @@ func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc) return mcp.NewToolResultError(fmt.Sprintf("failed to fork repository: %s", string(body))), nil } - r, err := json.Marshal(forkedRepo) + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + URL: forkedRepo.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index f5ebfd32b..468d7c29b 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -737,9 +737,33 @@ func Test_ListCommits(t *testing.T) { }, }, Author: &github.User{ - Login: github.Ptr("testuser"), + Login: github.Ptr("testuser"), + ID: github.Ptr(int64(12345)), + HTMLURL: github.Ptr("https://github.com/testuser"), + AvatarURL: github.Ptr("https://github.com/testuser.png"), }, HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + Stats: &github.CommitStats{ + Additions: github.Ptr(10), + Deletions: github.Ptr(5), + Total: github.Ptr(15), + }, + Files: []*github.CommitFile{ + { + Filename: github.Ptr("src/main.go"), + Status: github.Ptr("modified"), + Additions: github.Ptr(8), + Deletions: github.Ptr(3), + Changes: github.Ptr(11), + }, + { + Filename: github.Ptr("README.md"), + Status: github.Ptr("added"), + Additions: github.Ptr(2), + Deletions: github.Ptr(2), + Changes: github.Ptr(4), + }, + }, }, { SHA: github.Ptr("def456abc789"), @@ -752,9 +776,26 @@ func Test_ListCommits(t *testing.T) { }, }, Author: &github.User{ - Login: github.Ptr("anotheruser"), + Login: github.Ptr("anotheruser"), + ID: github.Ptr(int64(67890)), + HTMLURL: github.Ptr("https://github.com/anotheruser"), + AvatarURL: github.Ptr("https://github.com/anotheruser.png"), }, HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), + Stats: &github.CommitStats{ + Additions: github.Ptr(20), + Deletions: github.Ptr(10), + Total: github.Ptr(30), + }, + Files: []*github.CommitFile{ + { + Filename: github.Ptr("src/utils.go"), + Status: github.Ptr("added"), + Additions: github.Ptr(20), + Deletions: github.Ptr(10), + Changes: github.Ptr(30), + }, + }, }, } @@ -875,16 +916,23 @@ func Test_ListCommits(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var returnedCommits []*github.RepositoryCommit + var returnedCommits []MinimalCommit err = json.Unmarshal([]byte(textContent.Text), &returnedCommits) require.NoError(t, err) assert.Len(t, returnedCommits, len(tc.expectedCommits)) for i, commit := range returnedCommits { - assert.Equal(t, *tc.expectedCommits[i].Author, *commit.Author) - assert.Equal(t, *tc.expectedCommits[i].SHA, *commit.SHA) - assert.Equal(t, *tc.expectedCommits[i].Commit.Message, *commit.Commit.Message) - assert.Equal(t, *tc.expectedCommits[i].Author.Login, *commit.Author.Login) - assert.Equal(t, *tc.expectedCommits[i].HTMLURL, *commit.HTMLURL) + assert.Equal(t, tc.expectedCommits[i].GetSHA(), commit.SHA) + assert.Equal(t, tc.expectedCommits[i].GetHTMLURL(), commit.HTMLURL) + if tc.expectedCommits[i].Commit != nil { + assert.Equal(t, tc.expectedCommits[i].Commit.GetMessage(), commit.Commit.Message) + } + if tc.expectedCommits[i].Author != nil { + assert.Equal(t, tc.expectedCommits[i].Author.GetLogin(), commit.Author.Login) + } + + // Files and stats are never included in list_commits + assert.Nil(t, commit.Files) + assert.Nil(t, commit.Stats) } }) } @@ -1067,6 +1115,7 @@ func Test_CreateRepository(t *testing.T) { assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "name") assert.Contains(t, tool.InputSchema.Properties, "description") + assert.Contains(t, tool.InputSchema.Properties, "organization") assert.Contains(t, tool.InputSchema.Properties, "private") assert.Contains(t, tool.InputSchema.Properties, "autoInit") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"name"}) @@ -1077,7 +1126,6 @@ func Test_CreateRepository(t *testing.T) { Description: github.Ptr("Test repository"), Private: github.Ptr(true), HTMLURL: github.Ptr("https://github.com/testuser/test-repo"), - CloneURL: github.Ptr("https://github.com/testuser/test-repo.git"), CreatedAt: &github.Timestamp{Time: time.Now()}, Owner: &github.User{ Login: github.Ptr("testuser"), @@ -1119,6 +1167,34 @@ func Test_CreateRepository(t *testing.T) { expectError: false, expectedRepo: mockRepo, }, + { + name: "successful repository creation in organization", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/orgs/testorg/repos", + Method: "POST", + }, + expectRequestBody(t, map[string]interface{}{ + "name": "test-repo", + "description": "Test repository", + "private": false, + "auto_init": true, + }).andThen( + mockResponse(t, http.StatusCreated, mockRepo), + ), + ), + ), + requestArgs: map[string]interface{}{ + "name": "test-repo", + "description": "Test repository", + "organization": "testorg", + "private": false, + "autoInit": true, + }, + expectError: false, + expectedRepo: mockRepo, + }, { name: "successful repository creation with minimal parameters", mockedClient: mock.NewMockedHTTPClient( @@ -1192,17 +1268,13 @@ func Test_CreateRepository(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedRepo github.Repository + // Unmarshal and verify the minimal result + var returnedRepo MinimalResponse err = json.Unmarshal([]byte(textContent.Text), &returnedRepo) assert.NoError(t, err) // Verify repository details - assert.Equal(t, *tc.expectedRepo.Name, *returnedRepo.Name) - assert.Equal(t, *tc.expectedRepo.Description, *returnedRepo.Description) - assert.Equal(t, *tc.expectedRepo.Private, *returnedRepo.Private) - assert.Equal(t, *tc.expectedRepo.HTMLURL, *returnedRepo.HTMLURL) - assert.Equal(t, *tc.expectedRepo.Owner.Login, *returnedRepo.Owner.Login) + assert.Equal(t, tc.expectedRepo.GetHTMLURL(), returnedRepo.URL) }) } } diff --git a/pkg/github/search.go b/pkg/github/search.go index 248f17e17..55e4cf8b4 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -26,6 +26,10 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF mcp.Required(), mcp.Description("Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering."), ), + mcp.WithBoolean("minimal_output", + mcp.Description("Return minimal repository information (default: true). When false, returns full GitHub API repository objects."), + mcp.DefaultBool(true), + ), WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -37,7 +41,10 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF if err != nil { return mcp.NewToolResultError(err.Error()), nil } - + minimalOutput, err := OptionalBoolParamWithDefault(request, "minimal_output", true) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } opts := &github.SearchOptions{ ListOptions: github.ListOptions{ Page: pagination.Page, @@ -67,9 +74,55 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF return mcp.NewToolResultError(fmt.Sprintf("failed to search repositories: %s", string(body))), nil } - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + // Return either minimal or full response based on parameter + var r []byte + if minimalOutput { + minimalRepos := make([]MinimalRepository, 0, len(result.Repositories)) + for _, repo := range result.Repositories { + minimalRepo := MinimalRepository{ + ID: repo.GetID(), + Name: repo.GetName(), + FullName: repo.GetFullName(), + Description: repo.GetDescription(), + HTMLURL: repo.GetHTMLURL(), + Language: repo.GetLanguage(), + Stars: repo.GetStargazersCount(), + Forks: repo.GetForksCount(), + OpenIssues: repo.GetOpenIssuesCount(), + Private: repo.GetPrivate(), + Fork: repo.GetFork(), + Archived: repo.GetArchived(), + DefaultBranch: repo.GetDefaultBranch(), + } + + if repo.UpdatedAt != nil { + minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z") + } + if repo.CreatedAt != nil { + minimalRepo.CreatedAt = repo.CreatedAt.Format("2006-01-02T15:04:05Z") + } + if repo.Topics != nil { + minimalRepo.Topics = repo.Topics + } + + minimalRepos = append(minimalRepos, minimalRepo) + } + + minimalResult := &MinimalSearchRepositoriesResult{ + TotalCount: result.GetTotal(), + IncompleteResults: result.GetIncompleteResults(), + Items: minimalRepos, + } + + r, err = json.Marshal(minimalResult) + if err != nil { + return nil, fmt.Errorf("failed to marshal minimal response: %w", err) + } + } else { + r, err = json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal full response: %w", err) + } } return mcp.NewToolResultText(string(r)), nil @@ -156,21 +209,6 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to } } -// MinimalUser is the output type for user and organization search results. -type MinimalUser struct { - Login string `json:"login"` - ID int64 `json:"id,omitempty"` - ProfileURL string `json:"profile_url,omitempty"` - AvatarURL string `json:"avatar_url,omitempty"` - Details *UserDetails `json:"details,omitempty"` // Optional field for additional user details -} - -type MinimalSearchUsersResult struct { - TotalCount int `json:"total_count"` - IncompleteResults bool `json:"incomplete_results"` - Items []MinimalUser `json:"items"` -} - func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { query, err := RequiredParam[string](request, "query") diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index cfc87c02b..91ca45af5 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -148,23 +148,80 @@ func Test_SearchRepositories(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var returnedResult github.RepositoriesSearchResult + var returnedResult MinimalSearchRepositoriesResult err = json.Unmarshal([]byte(textContent.Text), &returnedResult) require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) - assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) - assert.Len(t, returnedResult.Repositories, len(tc.expectedResult.Repositories)) - for i, repo := range returnedResult.Repositories { - assert.Equal(t, *tc.expectedResult.Repositories[i].ID, *repo.ID) - assert.Equal(t, *tc.expectedResult.Repositories[i].Name, *repo.Name) - assert.Equal(t, *tc.expectedResult.Repositories[i].FullName, *repo.FullName) - assert.Equal(t, *tc.expectedResult.Repositories[i].HTMLURL, *repo.HTMLURL) + assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) + assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) + assert.Len(t, returnedResult.Items, len(tc.expectedResult.Repositories)) + for i, repo := range returnedResult.Items { + assert.Equal(t, *tc.expectedResult.Repositories[i].ID, repo.ID) + assert.Equal(t, *tc.expectedResult.Repositories[i].Name, repo.Name) + assert.Equal(t, *tc.expectedResult.Repositories[i].FullName, repo.FullName) + assert.Equal(t, *tc.expectedResult.Repositories[i].HTMLURL, repo.HTMLURL) } }) } } +func Test_SearchRepositories_FullOutput(t *testing.T) { + mockSearchResult := &github.RepositoriesSearchResult{ + Total: github.Ptr(1), + IncompleteResults: github.Ptr(false), + Repositories: []*github.Repository{ + { + ID: github.Ptr(int64(12345)), + Name: github.Ptr("test-repo"), + FullName: github.Ptr("owner/test-repo"), + HTMLURL: github.Ptr("https://github.com/owner/test-repo"), + Description: github.Ptr("Test repository"), + StargazersCount: github.Ptr(100), + }, + }, + } + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchRepositories, + expectQueryParams(t, map[string]string{ + "q": "golang test", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ) + + client := github.NewClient(mockedClient) + _, handlerTest := SearchRepositories(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(map[string]interface{}{ + "query": "golang test", + "minimal_output": false, + }) + + result, err := handlerTest(context.Background(), request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + // Unmarshal as full GitHub API response + var returnedResult github.RepositoriesSearchResult + err = json.Unmarshal([]byte(textContent.Text), &returnedResult) + require.NoError(t, err) + + // Verify it's the full API response, not minimal + assert.Equal(t, *mockSearchResult.Total, *returnedResult.Total) + assert.Equal(t, *mockSearchResult.IncompleteResults, *returnedResult.IncompleteResults) + assert.Len(t, returnedResult.Repositories, 1) + assert.Equal(t, *mockSearchResult.Repositories[0].ID, *returnedResult.Repositories[0].ID) + assert.Equal(t, *mockSearchResult.Repositories[0].Name, *returnedResult.Repositories[0].Name) +} + func Test_SearchCode(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) diff --git a/pkg/github/server.go b/pkg/github/server.go index 80a1bbac6..16d28643c 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -144,6 +144,21 @@ func OptionalIntParamWithDefault(r mcp.CallToolRequest, p string, d int) (int, e return v, nil } +// OptionalBoolParamWithDefault is a helper function that can be used to fetch a requested parameter from the request +// similar to optionalBoolParam, but it also takes a default value. +func OptionalBoolParamWithDefault(r mcp.CallToolRequest, p string, d bool) (bool, error) { + args := r.GetArguments() + _, ok := args[p] + v, err := OptionalParam[bool](r, p) + if err != nil { + return false, err + } + if !ok { + return d, nil + } + return v, nil +} + // OptionalStringArrayParam is a helper function that can be used to fetch a requested parameter from the request. // It does the following checks: // 1. Checks if the parameter is present in the request, if not, it returns its zero-value diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 728d78097..10a3f3eca 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -86,7 +86,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetPullRequestFiles(getClient, t)), toolsets.NewServerTool(SearchPullRequests(getClient, t)), toolsets.NewServerTool(GetPullRequestStatus(getClient, t)), - toolsets.NewServerTool(GetPullRequestComments(getClient, t)), + toolsets.NewServerTool(GetPullRequestReviewComments(getClient, t)), toolsets.NewServerTool(GetPullRequestReviews(getClient, t)), toolsets.NewServerTool(GetPullRequestDiff(getClient, t)), ).