diff --git a/.github/workflows/ai-issue-assessment.yml b/.github/workflows/ai-issue-assessment.yml index 7481ce6db..189ae959f 100644 --- a/.github/workflows/ai-issue-assessment.yml +++ b/.github/workflows/ai-issue-assessment.yml @@ -6,9 +6,6 @@ on: jobs: ai-issue-assessment: - if: > - (github.event.action == 'opened' && github.event.issue.labels[0] == null) || - (github.event.action == 'labeled' && github.event.label.name == 'bug') runs-on: ubuntu-latest permissions: issues: write @@ -23,8 +20,8 @@ jobs: uses: github/ai-assessment-comment-labeler@e3bedc38cfffa9179fe4cee8f7ecc93bffb3fee7 # v1.0.1 with: token: ${{ secrets.GITHUB_TOKEN }} - ai_review_label: 'bug, enhancement' + ai_review_label: "request ai review" issue_number: ${{ github.event.issue.number }} issue_body: ${{ github.event.issue.body }} - prompts_directory: '.github/prompts' - labels_to_prompts_mapping: 'bug,bug-report-review.prompt.yml|default,default-issue-review.prompt.yml' + prompts_directory: ".github/prompts" + labels_to_prompts_mapping: "bug,bug-report-review.prompt.yml|default,default-issue-review.prompt.yml" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml deleted file mode 100644 index 92524ea17..000000000 --- a/.github/workflows/conformance.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Conformance Test - -on: - pull_request: - -permissions: - contents: read - -jobs: - conformance: - runs-on: ubuntu-latest - - steps: - - name: Check out code - uses: actions/checkout@v6 - with: - # Fetch full history to access merge-base - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: "go.mod" - - - name: Download dependencies - run: go mod download - - - name: Run conformance test - id: conformance - run: | - # Run conformance test, capture stdout for summary - script/conformance-test > conformance-summary.txt 2>&1 || true - - # Output the summary - cat conformance-summary.txt - - # Check result - if grep -q "RESULT: ALL TESTS PASSED" conformance-summary.txt; then - echo "status=passed" >> $GITHUB_OUTPUT - else - echo "status=differences" >> $GITHUB_OUTPUT - fi - - - name: Generate Job Summary - run: | - # Add the full markdown report to the job summary - echo "# MCP Server Conformance Report" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Comparing PR branch against merge-base with \`origin/main\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # Extract and append the report content (skip the header since we added our own) - tail -n +5 conformance-report/CONFORMANCE_REPORT.md >> $GITHUB_STEP_SUMMARY - - echo "" >> $GITHUB_STEP_SUMMARY - echo "---" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # Add interpretation note - if [ "${{ steps.conformance.outputs.status }}" = "passed" ]; then - echo "✅ **All conformance tests passed** - No behavioral differences detected." >> $GITHUB_STEP_SUMMARY - else - echo "⚠️ **Differences detected** - Review the diffs above to ensure changes are intentional." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Common expected differences:" >> $GITHUB_STEP_SUMMARY - echo "- New tools/toolsets added" >> $GITHUB_STEP_SUMMARY - echo "- Tool descriptions updated" >> $GITHUB_STEP_SUMMARY - echo "- Capability changes (intentional improvements)" >> $GITHUB_STEP_SUMMARY - fi diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 9fca37208..181a99560 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -14,6 +14,14 @@ jobs: runs-on: ${{ matrix.os }} steps: + - name: Force git to use LF + # This step is required on Windows to work around go mod tidy -diff issues caused by CRLF line endings. + # TODO: replace with a checkout option when https://github.com/actions/checkout/issues/226 is implemented + if: runner.os == 'Windows' + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - name: Check out code uses: actions/checkout@v6 @@ -22,8 +30,8 @@ jobs: with: go-version-file: "go.mod" - - name: Download dependencies - run: go mod download + - name: Tidy dependencies + run: go mod tidy -diff - name: Run unit tests run: script/test diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml new file mode 100644 index 000000000..278bb8705 --- /dev/null +++ b/.github/workflows/issue-labeler.yml @@ -0,0 +1,19 @@ +name: Label issues for AI review +on: + issues: + types: + - reopened + - opened +jobs: + label_issues: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Add AI review label to issue + run: gh issue edit "$NUMBER" --add-label "$LABELS" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + LABELS: "request ai review" diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index ce2fa26fb..940773275 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -25,8 +25,12 @@ jobs: steps: - name: Check out code uses: actions/checkout@v6 - with: - ref: ${{ github.head_ref }} + + # Check out the actual PR branch so we can push changes back if needed + - name: Check out PR branch + env: + GH_TOKEN: ${{ github.token }} + run: gh pr checkout ${{ github.event.pull_request.number }} - name: Set up Go uses: actions/setup-go@v6 @@ -63,7 +67,7 @@ jobs: - name: Check if already commented if: steps.changes.outcome == 'failure' && steps.push.outcome == 'failure' id: check_comment - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const { data: comments } = await github.rest.issues.listComments({ @@ -81,7 +85,7 @@ jobs: - name: Comment with instructions if cannot push if: steps.changes.outcome == 'failure' && steps.push.outcome == 'failure' && steps.check_comment.outputs.already_commented == 'false' - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | await github.rest.issues.createComment({ diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml new file mode 100644 index 000000000..ba9b59c6e --- /dev/null +++ b/.github/workflows/mcp-diff.yml @@ -0,0 +1,72 @@ +name: MCP Server Diff + +on: + pull_request: + push: + branches: [main] + tags: ['v*'] + +permissions: + contents: read + +jobs: + mcp-diff: + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Run MCP Server Diff + uses: SamMorrowDrums/mcp-server-diff@v2.3.5 + with: + setup_go: "true" + install_command: go mod download + start_command: go run ./cmd/github-mcp-server stdio + env_vars: | + GITHUB_PERSONAL_ACCESS_TOKEN=test-token + configurations: | + [ + {"name": "default", "args": ""}, + {"name": "read-only", "args": "--read-only"}, + {"name": "dynamic-toolsets", "args": "--dynamic-toolsets"}, + {"name": "read-only+dynamic", "args": "--read-only --dynamic-toolsets"}, + {"name": "toolsets-repos", "args": "--toolsets=repos"}, + {"name": "toolsets-issues", "args": "--toolsets=issues"}, + {"name": "toolsets-context", "args": "--toolsets=context"}, + {"name": "toolsets-pull_requests", "args": "--toolsets=pull_requests"}, + {"name": "toolsets-repos,issues", "args": "--toolsets=repos,issues"}, + {"name": "toolsets-issues,context", "args": "--toolsets=issues,context"}, + {"name": "toolsets-all", "args": "--toolsets=all"}, + {"name": "tools-get_me", "args": "--tools=get_me"}, + {"name": "tools-get_me,list_issues", "args": "--tools=get_me,list_issues"}, + {"name": "toolsets-repos+read-only", "args": "--toolsets=repos --read-only"}, + {"name": "toolsets-all+dynamic", "args": "--toolsets=all --dynamic-toolsets"}, + {"name": "toolsets-repos+dynamic", "args": "--toolsets=repos --dynamic-toolsets"}, + {"name": "toolsets-repos,issues+dynamic", "args": "--toolsets=repos,issues --dynamic-toolsets"}, + { + "name": "dynamic-tool-calls", + "args": "--dynamic-toolsets", + "custom_messages": [ + {"id": 10, "name": "list_toolsets_before", "message": {"jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": {"name": "list_available_toolsets", "arguments": {}}}}, + {"id": 11, "name": "get_toolset_tools", "message": {"jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": {"name": "get_toolset_tools", "arguments": {"toolset": "repos"}}}}, + {"id": 12, "name": "enable_toolset", "message": {"jsonrpc": "2.0", "id": 12, "method": "tools/call", "params": {"name": "enable_toolset", "arguments": {"toolset": "repos"}}}}, + {"id": 13, "name": "list_toolsets_after", "message": {"jsonrpc": "2.0", "id": 13, "method": "tools/call", "params": {"name": "list_available_toolsets", "arguments": {}}}} + ] + } + ] + + - name: Add interpretation note + if: always() + run: | + echo "" >> $GITHUB_STEP_SUMMARY + echo "---" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "ℹ️ **Note:** Differences may be intentional improvements." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Common expected differences:" >> $GITHUB_STEP_SUMMARY + echo "- New tools/toolsets added" >> $GITHUB_STEP_SUMMARY + echo "- Tool descriptions updated" >> $GITHUB_STEP_SUMMARY + echo "- Capability changes (intentional improvements)" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 5684108b0..eedf65165 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ bin/ # binary github-mcp-server +mcpcurl +e2e.test .history conformance-report/ diff --git a/Dockerfile b/Dockerfile index 92ed52581..f804c03aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25.4-alpine AS build +FROM golang:1.25.6-alpine AS build ARG VERSION="dev" # Set the working directory @@ -14,7 +14,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ --mount=type=bind,target=. \ CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - -o /bin/github-mcp-server cmd/github-mcp-server/main.go + -o /bin/github-mcp-server ./cmd/github-mcp-server # Make a stage to run the app FROM gcr.io/distroless/base-debian12 diff --git a/README.md b/README.md index b015e984b..afe003002 100644 --- a/README.md +++ b/README.md @@ -80,10 +80,14 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block ### Install in other MCP hosts + +- **[Copilot CLI](/docs/installation-guides/install-copilot-cli.md)** - Installation guide for GitHub Copilot CLI - **[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 Applications](/docs/installation-guides/install-claude.md)** - Installation guide for Claude Web, Claude Desktop and Claude Code CLI +- **[Claude Applications](/docs/installation-guides/install-claude.md)** - Installation guide for Claude Desktop and Claude Code CLI +- **[Codex](/docs/installation-guides/install-codex.md)** - Installation guide for OpenAI Codex - **[Cursor](/docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE - **[Windsurf](/docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE +- **[Rovo Dev CLI](/docs/installation-guides/install-rovo-dev-cli.md)** - Installation guide for Rovo Dev CLI > **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. @@ -95,6 +99,49 @@ See [Remote Server Documentation](docs/remote-server.md) for full details on rem When no toolsets are specified, [default toolsets](#default-toolset) are used. +#### Insiders Mode + +> **Try new features early!** The remote server offers an insiders version with early access to new features and experimental tools. + + + + + + + +
Using URL PathUsing Header
+ +```json +{ + "servers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/insiders" + } + } +} +``` + + + +```json +{ + "servers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "X-MCP-Insiders": "true" + } + } + } +} +``` + +
+ +See [Remote Server Documentation](docs/remote-server.md#insiders-mode) for more details and examples. + #### GitHub Enterprise ##### GitHub Enterprise Cloud with data residency (ghe.com) @@ -102,6 +149,7 @@ When no toolsets are specified, [default toolsets](#default-toolset) are used. GitHub Enterprise Cloud can also make use of the remote server. Example for `https://octocorp.ghe.com` with GitHub PAT token: + ``` { ... @@ -131,31 +179,37 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to ### Prerequisites 1. To run the server in a container, you will need to have [Docker](https://www.docker.com/) installed. -2. Once Docker is installed, you will also need to ensure Docker is running. The image is public; if you get errors on pull, you may have an expired token and need to `docker logout ghcr.io`. +2. Once Docker is installed, you will also need to ensure Docker is running. The Docker image is available at `ghcr.io/github/github-mcp-server`. The image is public; if you get errors on pull, you may have an expired token and need to `docker logout ghcr.io`. 3. Lastly you will need to [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)).
Handling PATs Securely ### Environment Variables (Recommended) + To keep your GitHub PAT secure and reusable across different MCP hosts: 1. **Store your PAT in environment variables** + ```bash export GITHUB_PAT=your_token_here ``` + Or create a `.env` file: + ```env GITHUB_PAT=your_token_here ``` 2. **Protect your `.env` file** + ```bash # Add to .gitignore to prevent accidental commits echo ".env" >> .gitignore ``` 3. **Reference the token in configurations** + ```bash # CLI usage claude mcp update github -e GITHUB_PERSONAL_ACCESS_TOKEN=$GITHUB_PAT @@ -178,6 +232,7 @@ To keep your GitHub PAT secure and reusable across different MCP hosts: - **Regular rotation**: Update tokens periodically - **Never commit**: Keep tokens out of version control - **File permissions**: Restrict access to config files containing tokens + ```bash chmod 600 ~/.your-app/config.json ``` @@ -191,6 +246,7 @@ the hostname for GitHub Enterprise Server or GitHub Enterprise Cloud with data r - For GitHub Enterprise Server, prefix the hostname with the `https://` URI scheme, as it otherwise defaults to `http://`, which GitHub Enterprise Server does not support. - For GitHub Enterprise Cloud with data residency, use `https://YOURSUBDOMAIN.ghe.com` as the hostname. + ``` json "github": { "command": "docker", @@ -295,6 +351,7 @@ Optionally, you can add a similar example (i.e. without the mcp key) to a file c For other MCP host applications, please refer to our installation guides: +- **[Copilot CLI](docs/installation-guides/install-copilot-cli.md)** - Installation guide for GitHub Copilot CLI - **[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 @@ -326,6 +383,17 @@ If you don't have Docker, you can use `go build` to build the binary in the } ``` +### CLI utilities + +The `github-mcp-server` binary includes a few CLI subcommands that are helpful for debugging and exploring the server. + +- `github-mcp-server tool-search ""` searches tools by name, description, and input parameter names. Use `--max-results` to return more matches. +Example (color output requires a TTY; use `docker run -t` (or `-it`) when running in Docker): +```bash +docker run -it --rm ghcr.io/github/github-mcp-server tool-search "issue" --max-results 5 +github-mcp-server tool-search "issue" --max-results 5 +``` + ## Tool Configuration The GitHub MCP Server supports enabling or disabling specific groups of functionalities via the `--toolsets` flag. This allows you to control which GitHub API capabilities are available to your AI tools. Enabling only the toolsets that you need can help the LLM with tool choice and reduce the context size. @@ -347,6 +415,7 @@ To specify toolsets you want available to the LLM, you can pass an allow-list in ``` 2. **Using Environment Variable**: + ```bash GITHUB_TOOLSETS="repos,issues,pull_requests,actions,code_security" ./github-mcp-server ``` @@ -364,23 +433,29 @@ You can also configure specific tools using the `--tools` flag. Tools can be use ``` 2. **Using Environment Variable**: + ```bash GITHUB_TOOLS="get_file_contents,issue_read,create_pull_request" ./github-mcp-server ``` 3. **Combining with Toolsets** (additive): + ```bash github-mcp-server --toolsets repos,issues --tools get_gist ``` + This registers all tools from `repos` and `issues` toolsets, plus `get_gist`. 4. **Combining with Dynamic Toolsets** (additive): + ```bash github-mcp-server --tools get_file_contents --dynamic-toolsets ``` + This registers `get_file_contents` plus the dynamic toolset tools (`enable_toolset`, `list_available_toolsets`, `get_toolset_tools`). **Important Notes:** + - Tools, toolsets, and dynamic toolsets can all be used together - Read-only mode takes priority: write tools are skipped if `--read-only` is set, even if explicitly requested via `--tools` - Tool names must match exactly (e.g., `get_file_contents`, not `getFileContents`). Invalid tool names will cause the server to fail at startup with an error message @@ -433,9 +508,11 @@ GITHUB_TOOLSETS="all" ./github-mcp-server ``` #### "default" toolset + The default toolset `default` is the configuration that gets passed to the server if no toolsets are specified. The default configuration is: + - context - repos - issues @@ -448,6 +525,31 @@ To keep the default configuration and add additional toolsets: GITHUB_TOOLSETS="default,stargazers" ./github-mcp-server ``` +### Insiders Mode + +The local GitHub MCP Server offers an insiders version with early access to new features and experimental tools. + +1. **Using Command Line Argument**: + + ```bash + ./github-mcp-server --insiders + ``` + +2. **Using Environment Variable**: + + ```bash + GITHUB_INSIDERS=true ./github-mcp-server + ``` + +When using Docker: + +```bash +docker run -i --rm \ + -e GITHUB_PERSONAL_ACCESS_TOKEN= \ + -e GITHUB_INSIDERS=true \ + ghcr.io/github/github-mcp-server +``` + ### Available Toolsets The following sets of tools are available: @@ -491,6 +593,7 @@ The following sets of tools are available: workflow Actions - **actions_get** - Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts) + - **Required OAuth Scopes**: `repo` - `method`: The method to execute (string, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) @@ -502,6 +605,7 @@ The following sets of tools are available: (string, required) - **actions_list** - List GitHub Actions workflows in a repository + - **Required OAuth Scopes**: `repo` - `method`: The action to perform (string, required) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (default: 1) (number, optional) @@ -509,13 +613,14 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: - Do not provide any resource ID for 'list_workflows' method. - - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method. + - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method, or omit to list all workflow runs in the repository. - Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods. (string, optional) - `workflow_jobs_filter`: Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs' (object, optional) - `workflow_runs_filter`: Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs' (object, optional) - **actions_run_trigger** - Trigger GitHub Actions workflow actions + - **Required OAuth Scopes**: `repo` - `inputs`: Inputs the workflow accepts. Only used for 'run_workflow' method. (object, optional) - `method`: The method to execute (string, required) - `owner`: Repository owner (string, required) @@ -525,6 +630,7 @@ The following sets of tools are available: - `workflow_id`: The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method. (string, optional) - **get_job_logs** - Get GitHub Actions workflow job logs + - **Required OAuth Scopes**: `repo` - `failed_only`: When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided. (boolean, optional) - `job_id`: The unique identifier of the workflow job. Required when getting logs for a single job. (number, optional) - `owner`: Repository owner (string, required) @@ -540,11 +646,15 @@ The following sets of tools are available: codescan Code Security - **get_code_scanning_alert** - Get code scanning alert + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - **list_code_scanning_alerts** - List code scanning alerts + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `owner`: The owner of the repository. (string, required) - `ref`: The Git reference for the results you want to list. (string, optional) - `repo`: The name of the repository. (string, required) @@ -562,10 +672,14 @@ The following sets of tools are available: - No parameters required - **get_team_members** - Get team members + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` - `org`: Organization login (owner) that contains the team. (string, required) - `team_slug`: Team slug (string, required) - **get_teams** - Get teams + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` - `user`: Username to get teams for. If not provided, uses the authenticated user. (string, optional)
@@ -575,11 +689,15 @@ The following sets of tools are available: dependabot Dependabot - **get_dependabot_alert** - Get dependabot alert + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - **list_dependabot_alerts** - List dependabot alerts + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - `severity`: Filter dependabot alerts by severity (string, optional) @@ -592,11 +710,13 @@ The following sets of tools are available: comment-discussion Discussions - **get_discussion** - Get discussion + - **Required OAuth Scopes**: `repo` - `discussionNumber`: Discussion Number (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_discussion_comments** - Get discussion comments + - **Required OAuth Scopes**: `repo` - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `discussionNumber`: Discussion Number (number, required) - `owner`: Repository owner (string, required) @@ -604,10 +724,12 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **list_discussion_categories** - List discussion categories + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name. If not provided, discussion categories will be queried at the organisation level. (string, optional) - **list_discussions** - List discussions + - **Required OAuth Scopes**: `repo` - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `category`: Optional filter by discussion category ID. If provided, only discussions with this category are listed. (string, optional) - `direction`: Order direction. (string, optional) @@ -623,6 +745,7 @@ The following sets of tools are available: logo-gist Gists - **create_gist** - Create Gist + - **Required OAuth Scopes**: `gist` - `content`: Content for simple single-file gist creation (string, required) - `description`: Description of the gist (string, optional) - `filename`: Filename for simple single-file gist creation (string, required) @@ -638,6 +761,7 @@ The following sets of tools are available: - `username`: GitHub username (omit for authenticated user's gists) (string, optional) - **update_gist** - Update Gist + - **Required OAuth Scopes**: `gist` - `content`: Content for the file (string, required) - `description`: Updated description of the gist (string, optional) - `filename`: Filename to update or create (string, required) @@ -650,6 +774,7 @@ The following sets of tools are available: git-branch Git - **get_repository_tree** - Get repository tree + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (username or organization) (string, required) - `path_filter`: Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory) (string, optional) - `recursive`: Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false (boolean, optional) @@ -663,22 +788,28 @@ The following sets of tools are available: issue-opened Issues - **add_issue_comment** - Add comment to issue + - **Required OAuth Scopes**: `repo` - `body`: Comment content (string, required) - `issue_number`: Issue number to comment on (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **assign_copilot_to_issue** - Assign Copilot to issue - - `issueNumber`: Issue number (number, required) + - **Required OAuth Scopes**: `repo` + - `base_ref`: Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch (string, optional) + - `custom_instructions`: Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description (string, optional) + - `issue_number`: Issue number (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_label** - Get a specific label from a repository. + - **Required OAuth Scopes**: `repo` - `name`: Label name. (string, required) - `owner`: Repository owner (username or organization name) (string, required) - `repo`: Repository name (string, required) - **issue_read** - Get issue details + - **Required OAuth Scopes**: `repo` - `issue_number`: The number of the issue (number, required) - `method`: The read operation to perform on a single issue. Options are: @@ -693,6 +824,7 @@ The following sets of tools are available: - `repo`: The name of the repository (string, required) - **issue_write** - Create or update issue. + - **Required OAuth Scopes**: `repo` - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) @@ -712,9 +844,12 @@ The following sets of tools are available: - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) - **list_issue_types** - List available issue types + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` - `owner`: The organization owner of the repository (string, required) - **list_issues** - List issues + - **Required OAuth Scopes**: `repo` - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - `labels`: Filter by labels (string[], optional) @@ -726,6 +861,7 @@ The following sets of tools are available: - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) - **search_issues** - Search issues + - **Required OAuth Scopes**: `repo` - `order`: Sort order (string, optional) - `owner`: Optional repository owner. If provided with repo, only issues for this repository are listed. (string, optional) - `page`: Page number for pagination (min 1) (number, optional) @@ -735,6 +871,7 @@ The following sets of tools are available: - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) - **sub_issue_write** - Change sub-issue + - **Required OAuth Scopes**: `repo` - `after_id`: The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified) (number, optional) - `before_id`: The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified) (number, optional) - `issue_number`: The number of the parent issue (number, required) @@ -756,11 +893,13 @@ The following sets of tools are available: tag Labels - **get_label** - Get a specific label from a repository. + - **Required OAuth Scopes**: `repo` - `name`: Label name. (string, required) - `owner`: Repository owner (username or organization name) (string, required) - `repo`: Repository name (string, required) - **label_write** - Write operations on repository labels. + - **Required OAuth Scopes**: `repo` - `color`: Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'. (string, optional) - `description`: Label description text. Optional for 'create' and 'update'. (string, optional) - `method`: Operation to perform: 'create', 'update', or 'delete' (string, required) @@ -770,6 +909,7 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **list_label** - List labels from a repository + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (username or organization name) - required for all operations (string, required) - `repo`: Repository name - required for all operations (string, required) @@ -780,13 +920,16 @@ The following sets of tools are available: bell Notifications - **dismiss_notification** - Dismiss notification + - **Required OAuth Scopes**: `notifications` - `state`: The new state of the notification (read/done) (string, required) - `threadID`: The ID of the notification thread (string, required) - **get_notification_details** - Get notification details + - **Required OAuth Scopes**: `notifications` - `notificationID`: The ID of the notification (string, required) - **list_notifications** - List notifications + - **Required OAuth Scopes**: `notifications` - `before`: Only show notifications updated before the given time (ISO 8601 format) (string, optional) - `filter`: Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created. (string, optional) - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are listed. (string, optional) @@ -796,15 +939,18 @@ The following sets of tools are available: - `since`: Only show notifications updated after the given time (ISO 8601 format) (string, optional) - **manage_notification_subscription** - Manage notification subscription + - **Required OAuth Scopes**: `notifications` - `action`: Action to perform: ignore, watch, or delete the notification subscription. (string, required) - `notificationID`: The ID of the notification thread. (string, required) - **manage_repository_notification_subscription** - Manage repository notification subscription + - **Required OAuth Scopes**: `notifications` - `action`: Action to perform: ignore, watch, or delete the repository notification subscription. (string, required) - `owner`: The account owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - **mark_all_notifications_read** - Mark all notifications as read + - **Required OAuth Scopes**: `notifications` - `lastReadAt`: Describes the last point that notifications were checked (optional). Default: Now (string, optional) - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are marked as read. (string, optional) - `repo`: Optional repository name. If provided with owner, only notifications for this repository are marked as read. (string, optional) @@ -816,6 +962,8 @@ The following sets of tools are available: organization Organizations - **search_orgs** - Search organizations + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -828,69 +976,43 @@ The following sets of tools are available: project Projects -- **add_project_item** - Add project item - - `item_id`: The numeric ID of the issue or pull request to add to the project. (number, required) - - `item_type`: The item's type, either issue or pull_request. (string, required) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `project_number`: The project's number. (number, required) - -- **delete_project_item** - Delete project item - - `item_id`: The internal project item ID to delete from the project (not the issue or pull request ID). (number, required) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `project_number`: The project's number. (number, required) - -- **get_project** - Get project - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `project_number`: The project's number (number, required) - -- **get_project_field** - Get project field - - `field_id`: The field's id. (number, required) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `project_number`: The project's number. (number, required) - -- **get_project_item** - Get project item - - `fields`: Specific list of field IDs to include in the response (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. (string[], optional) - - `item_id`: The item's ID. (number, required) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `project_number`: The project's number. (number, required) - -- **list_project_fields** - List project fields - - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) - - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `per_page`: Results per page (max 50) (number, optional) - - `project_number`: The project's number. (number, required) - -- **list_project_items** - List project items - - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) - - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - - `fields`: Field IDs to include (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. (string[], optional) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `per_page`: Results per page (max 50) (number, optional) +- **projects_get** - Get details of GitHub Projects resources + - **Required OAuth Scopes**: `read:project` + - **Accepted OAuth Scopes**: `project`, `read:project` + - `field_id`: The field's ID. Required for 'get_project_field' method. (number, optional) + - `fields`: Specific list of field IDs to include in the response when getting a project item (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. Only used for 'get_project_item' method. (string[], optional) + - `item_id`: The item's ID. Required for 'get_project_item' method. (number, optional) + - `method`: The method to execute (string, required) + - `owner`: The owner (user or organization login). The name is not case sensitive. (string, required) + - `owner_type`: Owner type (user or org). If not provided, will be automatically detected. (string, optional) - `project_number`: The project's number. (number, required) - - `query`: Query string for advanced filtering of project items using GitHub's project filtering syntax. (string, optional) -- **list_projects** - List projects +- **projects_list** - List GitHub Projects resources + - **Required OAuth Scopes**: `read:project` + - **Accepted OAuth Scopes**: `project`, `read:project` - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) + - `fields`: Field IDs to include when listing project items (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method. (string[], optional) + - `method`: The action to perform (string, required) + - `owner`: The owner (user or organization login). The name is not case sensitive. (string, required) + - `owner_type`: Owner type (user or org). If not provided, will automatically try both. (string, optional) - `per_page`: Results per page (max 50) (number, optional) - - `query`: Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: "roadmap is:open", "is:open feature planning". (string, optional) - -- **update_project_item** - Update project item - - `item_id`: The unique identifier of the project item. This is not the issue or pull request ID. (number, required) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) + - `project_number`: The project's number. Required for 'list_project_fields' and 'list_project_items' methods. (number, optional) + - `query`: Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax. (string, optional) + +- **projects_write** - Modify GitHub Project items + - **Required OAuth Scopes**: `project` + - `issue_number`: The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number. (number, optional) + - `item_id`: The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. (number, optional) + - `item_owner`: The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method. (string, optional) + - `item_repo`: The name of the repository containing the issue or pull request. Required for 'add_project_item' method. (string, optional) + - `item_type`: The item's type, either issue or pull_request. Required for 'add_project_item' method. (string, optional) + - `method`: The method to execute (string, required) + - `owner`: The project owner (user or organization login). The name is not case sensitive. (string, required) + - `owner_type`: Owner type (user or org). If not provided, will be automatically detected. (string, optional) - `project_number`: The project's number. (number, required) - - `updated_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {"id": 123456, "value": "New Value"} (object, required) + - `pull_request_number`: The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number. (number, optional) + - `updated_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {"id": 123456, "value": "New Value"}. Required for 'update_project_item' method. (object, optional) @@ -899,6 +1021,7 @@ The following sets of tools are available: git-pull-request Pull Requests - **add_comment_to_pending_review** - Add review comment to the requester's latest pending pull request review + - **Required OAuth Scopes**: `repo` - `body`: The text of the review comment (string, required) - `line`: The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range (number, optional) - `owner`: Repository owner (string, required) @@ -911,6 +1034,7 @@ The following sets of tools are available: - `subjectType`: The level at which the comment is targeted (string, required) - **create_pull_request** - Open new pull request + - **Required OAuth Scopes**: `repo` - `base`: Branch to merge into (string, required) - `body`: PR description (string, optional) - `draft`: Create as draft PR (boolean, optional) @@ -921,6 +1045,7 @@ The following sets of tools are available: - `title`: PR title (string, required) - **list_pull_requests** - List pull requests + - **Required OAuth Scopes**: `repo` - `base`: Filter by base branch (string, optional) - `direction`: Sort direction (string, optional) - `head`: Filter by head user/org and branch (string, optional) @@ -932,6 +1057,7 @@ The following sets of tools are available: - `state`: Filter by state (string, optional) - **merge_pull_request** - Merge pull request + - **Required OAuth Scopes**: `repo` - `commit_message`: Extra detail for merge commit (string, optional) - `commit_title`: Title for merge commit (string, optional) - `merge_method`: Merge method (string, optional) @@ -940,6 +1066,7 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **pull_request_read** - Get details for a single pull request + - **Required OAuth Scopes**: `repo` - `method`: Action to specify what pull request data needs to be retrieved from GitHub. Possible options: 1. get - Get details of a specific pull request. @@ -957,6 +1084,7 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **pull_request_review_write** - Write operations (create, submit, delete) on pull request reviews. + - **Required OAuth Scopes**: `repo` - `body`: Review comment text (string, optional) - `commitID`: SHA of commit to review (string, optional) - `event`: Review action to perform. (string, optional) @@ -966,11 +1094,13 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **request_copilot_review** - Request Copilot review + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) - **search_pull_requests** - Search pull requests + - **Required OAuth Scopes**: `repo` - `order`: Sort order (string, optional) - `owner`: Optional repository owner. If provided with repo, only pull requests for this repository are listed. (string, optional) - `page`: Page number for pagination (min 1) (number, optional) @@ -980,6 +1110,7 @@ The following sets of tools are available: - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) - **update_pull_request** - Edit pull request + - **Required OAuth Scopes**: `repo` - `base`: New base branch name (string, optional) - `body`: New description (string, optional) - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) @@ -992,6 +1123,7 @@ The following sets of tools are available: - `title`: New title (string, optional) - **update_pull_request_branch** - Update pull request branch + - **Required OAuth Scopes**: `repo` - `expectedHeadSha`: The expected SHA of the pull request's HEAD ref (string, optional) - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) @@ -1004,12 +1136,14 @@ The following sets of tools are available: repo Repositories - **create_branch** - Create branch + - **Required OAuth Scopes**: `repo` - `branch`: Name for new branch (string, required) - `from_branch`: Source branch (defaults to repo default) (string, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **create_or_update_file** - Create or update file + - **Required OAuth Scopes**: `repo` - `branch`: Branch to create/update the file in (string, required) - `content`: Content of the file (string, required) - `message`: Commit message (string, required) @@ -1019,6 +1153,7 @@ The following sets of tools are available: - `sha`: The blob SHA of the file being replaced. (string, optional) - **create_repository** - Create repository + - **Required OAuth Scopes**: `repo` - `autoInit`: Initialize with README (boolean, optional) - `description`: Repository description (string, optional) - `name`: Repository name (string, required) @@ -1026,6 +1161,7 @@ The following sets of tools are available: - `private`: Whether repo should be private (boolean, optional) - **delete_file** - Delete file + - **Required OAuth Scopes**: `repo` - `branch`: Branch to delete the file from (string, required) - `message`: Commit message (string, required) - `owner`: Repository owner (username or organization) (string, required) @@ -1033,11 +1169,13 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **fork_repository** - Fork repository + - **Required OAuth Scopes**: `repo` - `organization`: Organization to fork to (string, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_commit** - Get commit details + - **Required OAuth Scopes**: `repo` - `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) @@ -1046,6 +1184,7 @@ The following sets of tools are available: - `sha`: Commit SHA, branch name, or tag name (string, required) - **get_file_contents** - Get file or directory contents + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (username or organization) (string, required) - `path`: Path to file/directory (string, optional) - `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional) @@ -1053,26 +1192,31 @@ The following sets of tools are available: - `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional) - **get_latest_release** - Get latest release + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_release_by_tag** - Get a release by tag name + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `tag`: Tag name (e.g., 'v1.0.0') (string, required) - **get_tag** - Get tag details + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `tag`: Tag name (string, required) - **list_branches** - List branches + - **Required OAuth Scopes**: `repo` - `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) - `repo`: Repository name (string, required) - **list_commits** - List commits + - **Required OAuth Scopes**: `repo` - `author`: Author username or email address to filter commits by (string, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) @@ -1081,18 +1225,21 @@ The following sets of tools are available: - `sha`: 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. (string, optional) - **list_releases** - List releases + - **Required OAuth Scopes**: `repo` - `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) - `repo`: Repository name (string, required) - **list_tags** - List tags + - **Required OAuth Scopes**: `repo` - `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) - `repo`: Repository name (string, required) - **push_files** - Push files to repository + - **Required OAuth Scopes**: `repo` - `branch`: Branch to push to (string, required) - `files`: Array of file objects to push, each object with path (string) and content (string) (object[], required) - `message`: Commit message (string, required) @@ -1100,6 +1247,7 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **search_code** - Search code + - **Required OAuth Scopes**: `repo` - `order`: Sort order for results (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -1107,6 +1255,7 @@ The following sets of tools are available: - `sort`: Sort field ('indexed' only) (string, optional) - **search_repositories** - Search repositories + - **Required OAuth Scopes**: `repo` - `minimal_output`: Return minimal repository information (default: true). When false, returns full GitHub API repository objects. (boolean, optional) - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) @@ -1121,11 +1270,15 @@ The following sets of tools are available: shield-lock Secret Protection - **get_secret_scanning_alert** - Get secret scanning alert + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - **list_secret_scanning_alerts** - List secret scanning alerts + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - `resolution`: Filter by resolution (string, optional) @@ -1139,9 +1292,13 @@ The following sets of tools are available: shield Security Advisories - **get_global_security_advisory** - Get a global security advisory + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `ghsaId`: GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). (string, required) - **list_global_security_advisories** - List global security advisories + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `affects`: Filter advisories by affected package or version (e.g. "package1,package2@1.0.0"). (string, optional) - `cveId`: Filter by CVE ID. (string, optional) - `cwes`: Filter by Common Weakness Enumeration IDs (e.g. ["79", "284", "22"]). (string[], optional) @@ -1155,12 +1312,16 @@ The following sets of tools are available: - `updated`: Filter by update date or date range (ISO 8601 date or range). (string, optional) - **list_org_repository_security_advisories** - List org repository security advisories + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `direction`: Sort direction. (string, optional) - `org`: The organization login. (string, required) - `sort`: Sort field. (string, optional) - `state`: Filter by advisory state. (string, optional) - **list_repository_security_advisories** - List repository security advisories + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `direction`: Sort direction. (string, optional) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) @@ -1174,6 +1335,7 @@ The following sets of tools are available: star Stargazers - **list_starred_repositories** - List starred repositories + - **Required OAuth Scopes**: `repo` - `direction`: The direction to sort the results by. (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -1181,10 +1343,12 @@ The following sets of tools are available: - `username`: Username to list starred repositories for. Defaults to the authenticated user. (string, optional) - **star_repository** - Star repository + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **unstar_repository** - Unstar repository + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) @@ -1195,6 +1359,7 @@ The following sets of tools are available: people Users - **search_users** - Search users + - **Required OAuth Scopes**: `repo` - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -1210,12 +1375,12 @@ The following sets of tools are available: Copilot -- **create_pull_request_with_copilot** - Perform task with GitHub Copilot coding agent - - `owner`: Repository owner. You can guess the owner, but confirm it with the user before proceeding. (string, required) - - `repo`: Repository name. You can guess the repository name, but confirm it with the user before proceeding. (string, required) - - `problem_statement`: Detailed description of the task to be performed (e.g., 'Implement a feature that does X', 'Fix bug Y', etc.) (string, required) - - `title`: Title for the pull request that will be created (string, required) - - `base_ref`: Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch (string, optional) +- **create_pull_request_with_copilot** - Perform task with GitHub Copilot coding agent + - `owner`: Repository owner. You can guess the owner, but confirm it with the user before proceeding. (string, required) + - `repo`: Repository name. You can guess the repository name, but confirm it with the user before proceeding. (string, required) + - `problem_statement`: Detailed description of the task to be performed (e.g., 'Implement a feature that does X', 'Fix bug Y', etc.) (string, required) + - `title`: Title for the pull request that will be created (string, required) + - `base_ref`: Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch (string, optional) @@ -1223,19 +1388,21 @@ The following sets of tools are available: Copilot Spaces -- **get_copilot_space** - Get Copilot Space - - `owner`: The owner of the space. (string, required) - - `name`: The name of the space. (string, required) +- **get_copilot_space** - Get Copilot Space + - `owner`: The owner of the space. (string, required) + - `name`: The name of the space. (string, required) + +- **list_copilot_spaces** - List Copilot Spaces -- **list_copilot_spaces** - List Copilot Spaces
GitHub Support Docs Search -- **github_support_docs_search** - Retrieve documentation relevant to answer GitHub product and support questions. Support topics include: GitHub Actions Workflows, Authentication, GitHub Support Inquiries, Pull Request Practices, Repository Maintenance, GitHub Pages, GitHub Packages, GitHub Discussions, Copilot Spaces - - `query`: Input from the user about the question they need answered. This is the latest raw unedited user message. You should ALWAYS leave the user message as it is, you should never modify it. (string, required) +- **github_support_docs_search** - Retrieve documentation relevant to answer GitHub product and support questions. Support topics include: GitHub Actions Workflows, Authentication, GitHub Support Inquiries, Pull Request Practices, Repository Maintenance, GitHub Pages, GitHub Packages, GitHub Discussions, Copilot Spaces + - `query`: Input from the user about the question they need answered. This is the latest raw unedited user message. You should ALWAYS leave the user message as it is, you should never modify it. (string, required) +
## Dynamic Tool Discovery diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index b40e3e2f4..78fd6c40a 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "net/url" "os" @@ -11,7 +12,6 @@ import ( "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/jsonschema-go/jsonschema" - "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/cobra" ) @@ -50,8 +50,9 @@ func generateReadmeDocs(readmePath string) error { // Create translation helper t, _ := translations.TranslationHelper() - // Build inventory - stateless, no dependencies needed for doc generation - r := github.NewInventory(t).Build() + // (not available to regular users) while including tools with FeatureFlagDisable. + // Build() can only fail if WithTools specifies invalid tools - not used here + r, _ := github.NewInventory(t).WithToolsets([]string{"all"}).Build() // Generate toolsets documentation toolsetsDoc := generateToolsetsDoc(r) @@ -153,9 +154,7 @@ func generateToolsetsDoc(i *inventory.Inventory) string { } func generateToolsDoc(r *inventory.Inventory) string { - // AllTools() returns tools sorted by toolset ID then tool name. - // We iterate once, grouping by toolset as we encounter them. - tools := r.AllTools() + tools := r.AvailableTools(context.Background()) if len(tools) == 0 { return "" } @@ -190,7 +189,7 @@ func generateToolsDoc(r *inventory.Inventory) string { currentToolsetID = tool.Toolset.ID currentToolsetIcon = tool.Toolset.Icon } - writeToolDoc(&toolBuf, tool.Tool) + writeToolDoc(&toolBuf, tool) toolBuf.WriteString("\n\n") } @@ -200,40 +199,26 @@ func generateToolsDoc(r *inventory.Inventory) string { return buf.String() } -func formatToolsetName(name string) string { - switch name { - case "pull_requests": - return "Pull Requests" - case "repos": - return "Repositories" - case "code_security": - return "Code Security" - case "secret_protection": - return "Secret Protection" - case "orgs": - return "Organizations" - default: - // Fallback: capitalize first letter and replace underscores with spaces - parts := strings.Split(name, "_") - for i, part := range parts { - if len(part) > 0 { - parts[i] = strings.ToUpper(string(part[0])) + part[1:] - } +func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { + // Tool name (no icon - section header already has the toolset icon) + fmt.Fprintf(buf, "- **%s** - %s\n", tool.Tool.Name, tool.Tool.Annotations.Title) + + // OAuth scopes if present + if len(tool.RequiredScopes) > 0 { + fmt.Fprintf(buf, " - **Required OAuth Scopes**: `%s`\n", strings.Join(tool.RequiredScopes, "`, `")) + + // Only show accepted scopes if they differ from required scopes + if len(tool.AcceptedScopes) > 0 && !scopesEqual(tool.RequiredScopes, tool.AcceptedScopes) { + fmt.Fprintf(buf, " - **Accepted OAuth Scopes**: `%s`\n", strings.Join(tool.AcceptedScopes, "`, `")) } - return strings.Join(parts, " ") } -} - -func writeToolDoc(buf *strings.Builder, tool mcp.Tool) { - // Tool name (no icon - section header already has the toolset icon) - fmt.Fprintf(buf, "- **%s** - %s\n", tool.Name, tool.Annotations.Title) // Parameters - if tool.InputSchema == nil { + if tool.Tool.InputSchema == nil { buf.WriteString(" - No parameters required") return } - schema, ok := tool.InputSchema.(*jsonschema.Schema) + schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema) if !ok || schema == nil { buf.WriteString(" - No parameters required") return @@ -282,6 +267,28 @@ func writeToolDoc(buf *strings.Builder, tool mcp.Tool) { } } +// scopesEqual checks if two scope slices contain the same elements (order-independent) +func scopesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + + // Create a map for quick lookup + aMap := make(map[string]bool, len(a)) + for _, scope := range a { + aMap[scope] = true + } + + // Check if all elements in b are in a + for _, scope := range b { + if !aMap[scope] { + return false + } + } + + return true +} + func contains(slice []string, item string) bool { for _, s := range slice { if s == item { @@ -335,7 +342,8 @@ func generateRemoteToolsetsDoc() string { t, _ := translations.TranslationHelper() // Build inventory - stateless - r := github.NewInventory(t).Build() + // Build() can only fail if WithTools specifies invalid tools - not used here + r, _ := github.NewInventory(t).Build() // Generate table header (icon is combined with Name column) buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n") @@ -343,14 +351,13 @@ func generateRemoteToolsetsDoc() string { // Add "all" toolset first (special case) allIcon := octiconImg("apps", "../") - fmt.Fprintf(&buf, "| %s
all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2F%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Freadonly%%22%%7D) |\n", allIcon) + fmt.Fprintf(&buf, "| %s
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2F%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Freadonly%%22%%7D) |\n", allIcon) // AvailableToolsets() returns toolsets that have tools, sorted by ID // Exclude context (handled separately) and dynamic (internal only) for _, ts := range r.AvailableToolsets("context", "dynamic") { idStr := string(ts.ID) - formattedName := formatToolsetName(idStr) apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr) readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr) @@ -366,9 +373,9 @@ func generateRemoteToolsetsDoc() string { readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig) icon := octiconImg(ts.Icon, "../") - fmt.Fprintf(&buf, "| %s
%s | %s | %s | %s | [read-only](%s) | %s |\n", + fmt.Fprintf(&buf, "| %s
`%s` | %s | %s | %s | [read-only](%s) | %s |\n", icon, - formattedName, + idStr, ts.Description, apiURL, installLink, @@ -391,7 +398,6 @@ func generateRemoteOnlyToolsetsDoc() string { for _, ts := range github.RemoteOnlyToolsets() { idStr := string(ts.ID) - formattedName := formatToolsetName(idStr) apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr) readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr) @@ -407,9 +413,9 @@ func generateRemoteOnlyToolsetsDoc() string { readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig) icon := octiconImg(ts.Icon, "../") - fmt.Fprintf(&buf, "| %s
%s | %s | %s | %s | [read-only](%s) | %s |\n", + fmt.Fprintf(&buf, "| %s
`%s` | %s | %s | %s | [read-only](%s) | %s |\n", icon, - formattedName, + idStr, ts.Description, apiURL, installLink, diff --git a/cmd/github-mcp-server/helpers.go b/cmd/github-mcp-server/helpers.go new file mode 100644 index 000000000..c5f498813 --- /dev/null +++ b/cmd/github-mcp-server/helpers.go @@ -0,0 +1,29 @@ +package main + +import "strings" + +// formatToolsetName converts a toolset ID to a human-readable name. +// Used by both generate_docs.go and list_scopes.go for consistent formatting. +func formatToolsetName(name string) string { + switch name { + case "pull_requests": + return "Pull Requests" + case "repos": + return "Repositories" + case "code_security": + return "Code Security" + case "secret_protection": + return "Secret Protection" + case "orgs": + return "Organizations" + default: + // Fallback: capitalize first letter and replace underscores with spaces + parts := strings.Split(name, "_") + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(string(part[0])) + part[1:] + } + } + return strings.Join(parts, " ") + } +} diff --git a/cmd/github-mcp-server/list_scopes.go b/cmd/github-mcp-server/list_scopes.go new file mode 100644 index 000000000..d8b8bf392 --- /dev/null +++ b/cmd/github-mcp-server/list_scopes.go @@ -0,0 +1,294 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// ToolScopeInfo contains scope information for a single tool. +type ToolScopeInfo struct { + Name string `json:"name"` + Toolset string `json:"toolset"` + ReadOnly bool `json:"read_only"` + RequiredScopes []string `json:"required_scopes"` + AcceptedScopes []string `json:"accepted_scopes,omitempty"` +} + +// ScopesOutput is the full output structure for the list-scopes command. +type ScopesOutput struct { + Tools []ToolScopeInfo `json:"tools"` + UniqueScopes []string `json:"unique_scopes"` + ScopesByTool map[string][]string `json:"scopes_by_tool"` + ToolsByScope map[string][]string `json:"tools_by_scope"` + EnabledToolsets []string `json:"enabled_toolsets"` + ReadOnly bool `json:"read_only"` +} + +var listScopesCmd = &cobra.Command{ + Use: "list-scopes", + Short: "List required OAuth scopes for enabled tools", + Long: `List the required OAuth scopes for all enabled tools. + +This command creates an inventory based on the same flags as the stdio command +and outputs the required OAuth scopes for each enabled tool. This is useful for +determining what scopes a token needs to use specific tools. + +The output format can be controlled with the --output flag: + - text (default): Human-readable text output + - json: JSON output for programmatic use + - summary: Just the unique scopes needed + +Examples: + # List scopes for default toolsets + github-mcp-server list-scopes + + # List scopes for specific toolsets + github-mcp-server list-scopes --toolsets=repos,issues,pull_requests + + # List scopes for all toolsets + github-mcp-server list-scopes --toolsets=all + + # Output as JSON + github-mcp-server list-scopes --output=json + + # Just show unique scopes needed + github-mcp-server list-scopes --output=summary`, + RunE: func(_ *cobra.Command, _ []string) error { + return runListScopes() + }, +} + +func init() { + listScopesCmd.Flags().StringP("output", "o", "text", "Output format: text, json, or summary") + _ = viper.BindPFlag("list-scopes-output", listScopesCmd.Flags().Lookup("output")) + + rootCmd.AddCommand(listScopesCmd) +} + +// formatScopeDisplay formats a scope string for display, handling empty scopes. +func formatScopeDisplay(scope string) string { + if scope == "" { + return "(no scope required for public read access)" + } + return scope +} + +func runListScopes() error { + // Get toolsets configuration (same logic as stdio command) + var enabledToolsets []string + if viper.IsSet("toolsets") { + if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { + return fmt.Errorf("failed to unmarshal toolsets: %w", err) + } + } + // else: enabledToolsets stays nil, meaning "use defaults" + + // Get specific tools (similar to toolsets) + var enabledTools []string + if viper.IsSet("tools") { + if err := viper.UnmarshalKey("tools", &enabledTools); err != nil { + return fmt.Errorf("failed to unmarshal tools: %w", err) + } + } + + readOnly := viper.GetBool("read-only") + outputFormat := viper.GetString("list-scopes-output") + + // Create translation helper + t, _ := translations.TranslationHelper() + + // Build inventory using the same logic as the stdio server + inventoryBuilder := github.NewInventory(t). + WithReadOnly(readOnly) + + // Configure toolsets (same as stdio) + if enabledToolsets != nil { + inventoryBuilder = inventoryBuilder.WithToolsets(enabledToolsets) + } + + // Configure specific tools + if len(enabledTools) > 0 { + inventoryBuilder = inventoryBuilder.WithTools(enabledTools) + } + + inv, err := inventoryBuilder.Build() + if err != nil { + return fmt.Errorf("failed to build inventory: %w", err) + } + + // Collect all tools and their scopes + output := collectToolScopes(inv, readOnly) + + // Output based on format + switch outputFormat { + case "json": + return outputJSON(output) + case "summary": + return outputSummary(output) + default: + return outputText(output) + } +} + +func collectToolScopes(inv *inventory.Inventory, readOnly bool) ScopesOutput { + var tools []ToolScopeInfo + scopeSet := make(map[string]bool) + scopesByTool := make(map[string][]string) + toolsByScope := make(map[string][]string) + + // Get all available tools from the inventory + // Use context.Background() for feature flag evaluation + availableTools := inv.AvailableTools(context.Background()) + + for _, serverTool := range availableTools { + tool := serverTool.Tool + + // Get scope information directly from ServerTool + requiredScopes := serverTool.RequiredScopes + acceptedScopes := serverTool.AcceptedScopes + + // Determine if tool is read-only + isReadOnly := serverTool.IsReadOnly() + + toolInfo := ToolScopeInfo{ + Name: tool.Name, + Toolset: string(serverTool.Toolset.ID), + ReadOnly: isReadOnly, + RequiredScopes: requiredScopes, + AcceptedScopes: acceptedScopes, + } + tools = append(tools, toolInfo) + + // Track unique scopes + for _, s := range requiredScopes { + scopeSet[s] = true + toolsByScope[s] = append(toolsByScope[s], tool.Name) + } + + // Track scopes by tool + scopesByTool[tool.Name] = requiredScopes + } + + // Sort tools by name + sort.Slice(tools, func(i, j int) bool { + return tools[i].Name < tools[j].Name + }) + + // Get unique scopes as sorted slice + var uniqueScopes []string + for s := range scopeSet { + uniqueScopes = append(uniqueScopes, s) + } + sort.Strings(uniqueScopes) + + // Sort tools within each scope + for scope := range toolsByScope { + sort.Strings(toolsByScope[scope]) + } + + // Get enabled toolsets as string slice + toolsetIDs := inv.ToolsetIDs() + toolsetIDStrs := make([]string, len(toolsetIDs)) + for i, id := range toolsetIDs { + toolsetIDStrs[i] = string(id) + } + + return ScopesOutput{ + Tools: tools, + UniqueScopes: uniqueScopes, + ScopesByTool: scopesByTool, + ToolsByScope: toolsByScope, + EnabledToolsets: toolsetIDStrs, + ReadOnly: readOnly, + } +} + +func outputJSON(output ScopesOutput) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(output) +} + +func outputSummary(output ScopesOutput) error { + if len(output.UniqueScopes) == 0 { + fmt.Println("No OAuth scopes required for enabled tools.") + return nil + } + + fmt.Println("Required OAuth scopes for enabled tools:") + fmt.Println() + for _, scope := range output.UniqueScopes { + fmt.Printf(" %s\n", formatScopeDisplay(scope)) + } + fmt.Printf("\nTotal: %d unique scope(s)\n", len(output.UniqueScopes)) + return nil +} + +func outputText(output ScopesOutput) error { + fmt.Printf("OAuth Scopes for Enabled Tools\n") + fmt.Printf("==============================\n\n") + + fmt.Printf("Enabled Toolsets: %s\n", strings.Join(output.EnabledToolsets, ", ")) + fmt.Printf("Read-Only Mode: %v\n\n", output.ReadOnly) + + // Group tools by toolset + toolsByToolset := make(map[string][]ToolScopeInfo) + for _, tool := range output.Tools { + toolsByToolset[tool.Toolset] = append(toolsByToolset[tool.Toolset], tool) + } + + // Get sorted toolset names + var toolsetNames []string + for name := range toolsByToolset { + toolsetNames = append(toolsetNames, name) + } + sort.Strings(toolsetNames) + + for _, toolsetName := range toolsetNames { + tools := toolsByToolset[toolsetName] + fmt.Printf("## %s\n\n", formatToolsetName(toolsetName)) + + for _, tool := range tools { + rwIndicator := "📝" + if tool.ReadOnly { + rwIndicator = "👁" + } + + scopeStr := "(no scope required)" + if len(tool.RequiredScopes) > 0 { + scopeStr = strings.Join(tool.RequiredScopes, ", ") + } + + fmt.Printf(" %s %s: %s\n", rwIndicator, tool.Name, scopeStr) + } + fmt.Println() + } + + // Summary + fmt.Println("## Summary") + fmt.Println() + if len(output.UniqueScopes) == 0 { + fmt.Println("No OAuth scopes required for enabled tools.") + } else { + fmt.Println("Unique scopes required:") + for _, scope := range output.UniqueScopes { + fmt.Printf(" • %s\n", formatScopeDisplay(scope)) + } + } + fmt.Printf("\nTotal: %d tools, %d unique scopes\n", len(output.Tools), len(output.UniqueScopes)) + + // Legend + fmt.Println("\nLegend: 👁 = read-only, 📝 = read-write") + + return nil +} diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index cfb68be4e..c361a4d5a 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -83,6 +83,7 @@ var ( LogFilePath: viper.GetString("log-file"), ContentWindowSize: viper.GetInt("content-window-size"), LockdownMode: viper.GetBool("lockdown-mode"), + InsidersMode: viper.GetBool("insiders"), RepoAccessCacheTTL: &ttl, } return ghmcp.RunStdioServer(stdioServerConfig) @@ -108,6 +109,7 @@ func init() { rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)") rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size") rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode") + rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") // Bind flag to viper @@ -122,6 +124,7 @@ func init() { _ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host")) _ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size")) _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) + _ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) // Add subcommands @@ -133,7 +136,6 @@ func initConfig() { viper.SetEnvPrefix("github") viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) viper.AutomaticEnv() - } func main() { diff --git a/cmd/mcpcurl/README.md b/cmd/mcpcurl/README.md index 717ea207f..e1227d585 100644 --- a/cmd/mcpcurl/README.md +++ b/cmd/mcpcurl/README.md @@ -16,7 +16,7 @@ be executed against the configured MCP server. ## Installation ### Prerequisites -- Go 1.21 or later +- Go 1.24 or later - Access to the GitHub MCP Server from either Docker or local build ### Build from Source diff --git a/cmd/mcpcurl/mcpcurl b/cmd/mcpcurl/mcpcurl deleted file mode 100755 index 6ea4eeda6..000000000 Binary files a/cmd/mcpcurl/mcpcurl and /dev/null differ diff --git a/docs/installation-guides/README.md b/docs/installation-guides/README.md index be967f81d..4a300e3f4 100644 --- a/docs/installation-guides/README.md +++ b/docs/installation-guides/README.md @@ -3,6 +3,7 @@ This directory contains detailed installation instructions for the GitHub MCP Server across different host applications and IDEs. Choose the guide that matches your development environment. ## Installation Guides by Host Application +- **[Copilot CLI](install-copilot-cli.md)** - Installation guide for GitHub Copilot CLI - **[GitHub Copilot in other IDEs](install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot - **[Antigravity](install-antigravity.md)** - Installation for Google Antigravity IDE - **[Claude Applications](install-claude.md)** - Installation guide for Claude Web, Claude Desktop and Claude Code CLI @@ -15,6 +16,7 @@ This directory contains detailed installation instructions for the GitHub MCP Se | Host Application | Local GitHub MCP Support | Remote GitHub MCP Support | Prerequisites | Difficulty | |-----------------|---------------|----------------|---------------|------------| +| Copilot CLI | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Copilot in VS Code | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: VS Code 1.101+ | Easy | | Copilot Coding Agent | ✅ | ✅ Full (on by default; no auth needed) | Any _paid_ copilot license | Default on | | Copilot in Visual Studio | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: Visual Studio 17.14+ | Easy | diff --git a/docs/installation-guides/install-claude.md b/docs/installation-guides/install-claude.md index 1a5b789f4..05e3c3739 100644 --- a/docs/installation-guides/install-claude.md +++ b/docs/installation-guides/install-claude.md @@ -28,22 +28,34 @@ echo -e ".env\n.mcp.json" >> .gitignore ### Remote Server Setup (Streamable HTTP) -1. Run the following command in the Claude Code CLI +> **Note**: For Claude Code versions **2.1.1 and newer**, use the `add-json` command format below. For older versions, see the [legacy command format](#for-older-versions-of-claude-code). +> +> **Windows / CLI note**: `claude mcp add-json` may return `Invalid input` when adding an HTTP server. If that happens, use the legacy `claude mcp add --transport http ...` command format below. + +1. Run the following command in the terminal (not in Claude Code CLI): ```bash -claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer YOUR_GITHUB_PAT" +claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer YOUR_GITHUB_PAT"}}' ``` With an environment variable: ```bash -claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer $(grep GITHUB_PAT .env | cut -d '=' -f2)" +claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer '"$(grep GITHUB_PAT .env | cut -d '=' -f2)"'"}}' ``` + +> **About the `--scope` flag** (optional): Use this to specify where the configuration is stored: +> - `local` (default): Available only to you in the current project (was called `project` in older versions) +> - `project`: Shared with everyone in the project via `.mcp.json` file +> - `user`: Available to you across all projects (was called `global` in older versions) +> +> Example: Add `--scope user` to the end of the command to make it available across all projects. + 2. Restart Claude Code 3. Run `claude mcp list` to see if the GitHub server is configured ### Local Server Setup (Docker required) ### With Docker -1. Run the following command in the Claude Code CLI: +1. Run the following command in the terminal (not in Claude Code CLI): ```bash claude mcp add github -e GITHUB_PERSONAL_ACCESS_TOKEN=YOUR_GITHUB_PAT -- docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server ``` @@ -72,6 +84,28 @@ claude mcp list claude mcp get github ``` +### For Older Versions of Claude Code + +If you're using Claude Code version **2.1.0 or earlier**, use this legacy command format: + +```bash +claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer YOUR_GITHUB_PAT" +``` + +With an environment variable: +```bash +claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer $(grep GITHUB_PAT .env | cut -d '=' -f2)" +``` + +#### Windows (PowerShell) + +If you see `missing required argument 'name'`, put the server name immediately after `claude mcp add`: + +```powershell +$pat = "YOUR_GITHUB_PAT" +claude mcp add github --transport http https://api.githubcopilot.com/mcp/ -H "Authorization: Bearer $pat" +``` + --- ## Claude Desktop @@ -161,7 +195,4 @@ Add this codeblock to your `claude_desktop_config.json`: - The npm package `@modelcontextprotocol/server-github` is deprecated as of April 2025 - Remote server requires Streamable HTTP support (check your Claude version) -- Configuration scopes for Claude Code: - - `-s user`: Available across all projects - - `-s project`: Shared via `.mcp.json` file - - Default: `local` (current project only) +- For Claude Code configuration scopes, see the `--scope` flag documentation in the [Remote Server Setup](#remote-server-setup-streamable-http) section diff --git a/docs/installation-guides/install-copilot-cli.md b/docs/installation-guides/install-copilot-cli.md new file mode 100644 index 000000000..5f95a03ef --- /dev/null +++ b/docs/installation-guides/install-copilot-cli.md @@ -0,0 +1,136 @@ +# Install GitHub MCP Server in Copilot CLI + +## Prerequisites + +1. Copilot CLI installed (see [official Copilot CLI documentation](https://docs.github.com/en/copilot/concepts/agents/about-copilot-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 +
+ +To set your PAT as an environment variable: + +```bash +# Add to your shell profile (~/.bashrc, ~/.zshrc, etc.) +export GITHUB_PERSONAL_ACCESS_TOKEN=your_token_here +``` + +
+ +## GitHub MCP Server Configuration + +You can configure the GitHub MCP server in Copilot CLI using either the interactive command or by manually editing the configuration file. + +### Method 1: Interactive Setup (Recommended) + +Use the Copilot CLI to interactively add the MCP server: + +```bash +/mcp add +``` + +Follow the prompts to configure the GitHub MCP server. + +### Method 2: Manual Configuration + +Create or edit the configuration file `~/.copilot/mcp-config.json` and add one of the following configurations: + +#### Remote Server + +Connect to the hosted MCP server: + +```json +{ + "mcpServers": { + "github": { + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer ${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} +``` + +#### Local Docker + +With Docker running, you can run the GitHub MCP server in a container: + +```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_PERSONAL_ACCESS_TOKEN}" + } + } + } +} +``` + +#### 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 Copilot CLI with: + +```json +{ + "mcpServers": { + "github": { + "command": "/path/to/binary", + "args": ["stdio"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} +``` + +## Verification + +To verify that the GitHub MCP server has been configured: + +1. Start or restart Copilot CLI +2. The GitHub tools should be available for use in your conversations + +## 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 ~/.copilot/mcp-config.json | jq . + ``` + +## References + +- [Copilot CLI Documentation](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) diff --git a/docs/installation-guides/install-rovo-dev-cli.md b/docs/installation-guides/install-rovo-dev-cli.md new file mode 100644 index 000000000..e6660bfe4 --- /dev/null +++ b/docs/installation-guides/install-rovo-dev-cli.md @@ -0,0 +1,32 @@ +# Install GitHub MCP Server in Rovo Dev CLI + +## Prerequisites + +1. Rovo Dev CLI installed (latest version) +2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes + +## MCP Server Setup + +Uses GitHub's hosted server at https://api.githubcopilot.com/mcp/. + +### Install steps + +1. Run `acli rovodev mcp` to open the MCP configuration for Rovo Dev CLI +2. Add configuration by following example below. +3. Replace `YOUR_GITHUB_PAT` with your actual [GitHub Personal Access Token](https://github.com/settings/tokens) +4. Save the file and restart Rovo Dev CLI with `acli rovodev` + +### Example configuration + +```json +{ + "mcpServers": { + "github": { + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer YOUR_GITHUB_PAT" + } + } + } +} +``` diff --git a/docs/remote-server.md b/docs/remote-server.md index d7d0f72b1..149667393 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,24 +19,24 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | | ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- | -| apps
all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | -| workflow
Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | -| codescan
Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | -| dependabot
Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | -| comment-discussion
Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | -| logo-gist
Gists | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | -| git-branch
Git | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) | -| issue-opened
Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | -| tag
Labels | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) | -| bell
Notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | -| organization
Organizations | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) | -| project
Projects | GitHub Projects related tools | https://api.githubcopilot.com/mcp/x/projects | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/projects/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%2Freadonly%22%7D) | -| git-pull-request
Pull Requests | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) | -| repo
Repositories | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | -| shield-lock
Secret Protection | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) | -| shield
Security Advisories | Security advisories related tools | https://api.githubcopilot.com/mcp/x/security_advisories | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/security_advisories/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%2Freadonly%22%7D) | -| star
Stargazers | GitHub Stargazers related tools | https://api.githubcopilot.com/mcp/x/stargazers | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/stargazers/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%2Freadonly%22%7D) | -| people
Users | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) | +| apps
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| workflow
`actions` | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | +| codescan
`code_security` | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | +| dependabot
`dependabot` | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | +| comment-discussion
`discussions` | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | +| logo-gist
`gists` | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | +| git-branch
`git` | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) | +| issue-opened
`issues` | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | +| tag
`labels` | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) | +| bell
`notifications` | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | +| organization
`orgs` | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) | +| project
`projects` | GitHub Projects related tools | https://api.githubcopilot.com/mcp/x/projects | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/projects/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%2Freadonly%22%7D) | +| git-pull-request
`pull_requests` | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) | +| repo
`repos` | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | +| shield-lock
`secret_protection` | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) | +| shield
`security_advisories` | Security advisories related tools | https://api.githubcopilot.com/mcp/x/security_advisories | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/security_advisories/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%2Freadonly%22%7D) | +| star
`stargazers` | GitHub Stargazers related tools | https://api.githubcopilot.com/mcp/x/stargazers | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/stargazers/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%2Freadonly%22%7D) | +| people
`users` | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) | ### Additional _Remote_ Server Toolsets @@ -46,9 +46,9 @@ These toolsets are only available in the remote GitHub MCP Server and are not in | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | | ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- | -| copilot
Copilot | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | -| copilot
Copilot Spaces | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | -| book
Github Support Docs Search | Retrieve documentation to answer GitHub product and support questions. Topics include: GitHub Actions Workflows, Authentication, ... | https://api.githubcopilot.com/mcp/x/github_support_docs_search | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/github_support_docs_search/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%2Freadonly%22%7D) | +| copilot
`copilot` | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | +| copilot
`copilot_spaces` | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | +| book
`github_support_docs_search` | Retrieve documentation to answer GitHub product and support questions. Topics include: GitHub Actions Workflows, Authentication, ... | https://api.githubcopilot.com/mcp/x/github_support_docs_search | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/github_support_docs_search/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%2Freadonly%22%7D) | ### Optional Headers @@ -67,6 +67,9 @@ The Remote GitHub MCP server has optional headers equivalent to the Local server - `X-MCP-Lockdown`: Enables lockdown mode, hiding public issue details created by users without push access. - Equivalent to `GITHUB_LOCKDOWN_MODE` env var for Local server. - If this header is empty, "false", "f", "no", "n", "0", or "off" (ignoring whitespace and case), it will be interpreted as false. All other values are interpreted as true. +- `X-MCP-Insiders`: Enables insiders mode for early access to new features. + - Equivalent to `GITHUB_INSIDERS` env var or `--insiders` flag for Local server. + - If this header is empty, "false", "f", "no", "n", "0", or "off" (ignoring whitespace and case), it will be interpreted as false. All other values are interpreted as true. > **Looking for examples?** See the [Server Configuration Guide](./server-configuration.md) for common recipes like minimal setups, read-only mode, and combining tools with toolsets. @@ -84,18 +87,49 @@ Example: } ``` +### Insiders Mode + +The remote GitHub MCP Server offers an insiders version with early access to new features and experimental tools. You can enable insiders mode in two ways: + +1. **Via URL path** - Append `/insiders` to the URL: + + ```json + { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/insiders" + } + ``` + +2. **Via header** - Set the `X-MCP-Insiders` header to `true`: + + ```json + { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "X-MCP-Insiders": "true" + } + } + ``` + +Both methods can be combined with other path modifiers (like `/readonly`) and headers. + ### URL Path Parameters The Remote GitHub MCP server supports the following URL path patterns: - `/` - Default toolset (see ["default" toolset](../README.md#default-toolset)) - `/readonly` - Default toolset in read-only mode +- `/insiders` - Default toolset with insiders mode enabled +- `/insiders/readonly` - Default toolset with insiders mode in read-only mode - `/x/all` - All available toolsets - `/x/all/readonly` - All available toolsets in read-only mode +- `/x/all/insiders` - All available toolsets with insiders mode enabled - `/x/{toolset}` - Single specific toolset - `/x/{toolset}/readonly` - Single specific toolset in read-only mode +- `/x/{toolset}/insiders` - Single specific toolset with insiders mode enabled -Note: `{toolset}` can only be a single toolset, not a comma-separated list. To combine multiple toolsets, use the `X-MCP-Toolsets` header instead. +Note: `{toolset}` can only be a single toolset, not a comma-separated list. To combine multiple toolsets, use the `X-MCP-Toolsets` header instead. Path modifiers like `/readonly` and `/insiders` can be combined with the `X-MCP-Insiders` or `X-MCP-Readonly` headers. Example: diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md new file mode 100644 index 000000000..f29d631ca --- /dev/null +++ b/docs/scope-filtering.md @@ -0,0 +1,103 @@ +# PAT Scope Filtering + +The GitHub MCP Server automatically filters available tools based on your classic Personal Access Token's (PAT) OAuth scopes. This ensures you only see tools that your token has permission to use, reducing clutter and preventing errors from attempting operations your token can't perform. + +> **Note:** This feature applies to **classic PATs** (tokens starting with `ghp_`). Fine-grained PATs, GitHub App installation tokens, and server-to-server tokens don't support scope detection and show all tools. + +## How It Works + +When the server starts with a classic PAT, it makes a lightweight HTTP HEAD request to the GitHub API to discover your token's scopes from the `X-OAuth-Scopes` header. Tools that require scopes your token doesn't have are automatically hidden. + +**Example:** If your token only has `repo` and `gist` scopes, you won't see tools that require `admin:org`, `project`, or `notifications` scopes. + +## PAT vs OAuth Authentication + +| Authentication | Scope Handling | +|---------------|----------------| +| **Classic PAT** (`ghp_`) | Filters tools at startup based on token scopes—tools requiring unavailable scopes are hidden | +| **OAuth** (remote server only) | Uses OAuth scope challenges—when a tool needs a scope you haven't granted, you're prompted to authorize it | +| **Fine-grained PAT** (`github_pat_`) | No filtering—all tools shown, API enforces permissions | +| **GitHub App** (`ghs_`) | No filtering—all tools shown, permissions based on app installation | +| **Server-to-server** | No filtering—all tools shown, permissions based on app/token configuration | + +With OAuth, the remote server can dynamically request additional scopes as needed. With PATs, scopes are fixed at token creation, so the server proactively hides tools you can't use. + +## OAuth Scope Challenges (Remote Server) + +When using the [remote MCP server](./remote-server.md) with OAuth authentication, the server uses a different approach called **scope challenges**. Instead of hiding tools upfront, all tools are available, and the server requests additional scopes on-demand when you try to use a tool that requires them. + +**How it works:** +1. You attempt to use a tool (e.g., creating an issue) +2. If your current OAuth token lacks the required scope, the server returns an OAuth scope challenge +3. Your MCP client prompts you to authorize the additional scope +4. After authorization, the operation completes successfully + +This provides a smoother user experience for OAuth users since you only grant permissions as needed, rather than requesting all scopes upfront. + +## Checking Your Token's Scopes + +To see what scopes your token has, you can run: + +```bash +curl -sI -H "Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN" \ + https://api.github.com/user | grep -i x-oauth-scopes +``` + +Example output: +``` +x-oauth-scopes: delete_repo, gist, read:org, repo +``` + +## Scope Hierarchy + +Some scopes implicitly include others: + +- `repo` → includes `public_repo`, `security_events` +- `admin:org` → includes `write:org` → includes `read:org` +- `project` → includes `read:project` + +This means if your token has `repo`, tools requiring `security_events` will also be available. + +Each tool in the [README](../README.md#tools) lists its required and accepted OAuth scopes. + +## Public Repository Access + +Read-only tools that only require `repo` or `public_repo` scopes are **always visible**, even if your token doesn't have these scopes. This is because these tools work on public repositories without authentication. + +For example, `get_file_contents` is always available—you can read files from any public repository regardless of your token's scopes. However, write operations like `create_or_update_file` will be hidden if your token lacks `repo` scope. + +> **Note:** The GitHub API doesn't return `public_repo` in the `X-OAuth-Scopes` header—it's implicit. The server handles this by not filtering read-only repository tools. + +## Graceful Degradation + +If the server cannot fetch your token's scopes (e.g., network issues, rate limiting), it logs a warning and continues **without filtering**. This ensures the server remains usable even when scope detection fails. + +``` +WARN: failed to fetch token scopes, continuing without scope filtering +``` + +## Classic vs Fine-Grained Personal Access Tokens + +**Classic PATs** (`ghp_` prefix) support OAuth scopes and return them in the `X-OAuth-Scopes` header. Scope filtering works fully with these tokens. + +**Fine-grained PATs** (`github_pat_` prefix) use a different permission model based on repository access and specific permissions rather than OAuth scopes. They don't return the `X-OAuth-Scopes` header, so scope filtering is skipped. All tools will be available, but the GitHub API will still enforce permissions at the API level—you'll get errors if you try to use tools your token doesn't have permission for. + +## GitHub App and Server-to-Server Tokens + +**GitHub App installation tokens** (`ghs_` prefix) and other server-to-server tokens use a permission model based on the app's installation permissions rather than OAuth scopes. These tokens don't return the `X-OAuth-Scopes` header, so scope filtering is skipped. The GitHub API enforces permissions based on the app's configuration. + +## Troubleshooting + +| Problem | Cause | Solution | +|---------|-------|----------| +| Missing expected tools | Token lacks required scope | [Edit your PAT's scopes](https://github.com/settings/tokens) in GitHub settings | +| All tools visible despite limited PAT | Scope detection failed | Check logs for warnings about scope fetching | +| "Insufficient permissions" errors | Tool visible but scope insufficient | This shouldn't happen with scope filtering; report as bug | + +> **Tip:** You can adjust the scopes of an existing classic PAT at any time via [GitHub's token settings](https://github.com/settings/tokens). After updating scopes, restart the MCP server to pick up the changes. + +## Related Documentation + +- [Server Configuration Guide](./server-configuration.md) +- [GitHub PAT Documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) +- [OAuth Scopes Reference](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps) diff --git a/docs/server-configuration.md b/docs/server-configuration.md index e8b7637bd..46ec3bc64 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -12,6 +12,7 @@ We currently support the following ways in which the GitHub MCP Server can be co | Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var | | Dynamic Mode | Not available | `--dynamic-toolsets` flag or `GITHUB_DYNAMIC_TOOLSETS` env var | | Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var | +| Scope Filtering | Always enabled | Always enabled | > **Default behavior:** If you don't specify any configuration, the server uses the **default toolsets**: `context`, `issues`, `pull_requests`, `repos`, `users`. @@ -330,6 +331,20 @@ Lockdown mode ensures the server only surfaces content in public repositories fr --- +### Scope Filtering + +**Automatic feature:** The server handles OAuth scopes differently depending on authentication type: + +- **Classic PATs** (`ghp_` prefix): Tools are filtered at startup based on token scopes—you only see tools you have permission to use +- **OAuth** (remote server): Uses scope challenges—when a tool needs a scope you haven't granted, you're prompted to authorize it +- **Other tokens**: No filtering—all tools shown, API enforces permissions + +This happens transparently—no configuration needed. If scope detection fails for a classic PAT (e.g., network issues), the server logs a warning and continues with all tools available. + +See [Scope Filtering](./scope-filtering.md) for details on how filtering works with different token types. + +--- + ## Troubleshooting | Problem | Cause | Solution | diff --git a/docs/testing.md b/docs/testing.md index 226660e9d..2186b564b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,7 +7,7 @@ This project uses a combination of unit tests and end-to-end (e2e) tests to ensu - Unit tests are located alongside implementation, with filenames ending in `_test.go`. - Currently the preference is to use internal tests i.e. test files do not have `_test` package suffix. - Tests use [testify](https://github.com/stretchr/testify) for assertions and require statements. Use `require` when continuing the test is not meaningful, for example it is almost never correct to continue after an error expectation. -- Mocking is performed using [go-github-mock](https://github.com/migueleliasweb/go-github-mock) or `githubv4mock` for simulating GitHub rest and GQL API responses. +- REST mocking is performed with the in-repo `MockHTTPClientWithHandlers` helpers; GraphQL mocking uses `githubv4mock`. - Each tool's schema is snapshotted and checked for changes using the `toolsnaps` utility (see below). - Tests are designed to be explicit and verbose to aid maintainability and clarity. - Handler unit tests should take the form of: diff --git a/docs/tool-renaming.md b/docs/tool-renaming.md index cf342f6dc..050ac9b77 100644 --- a/docs/tool-renaming.md +++ b/docs/tool-renaming.md @@ -46,15 +46,23 @@ Will get `issue_read` and `get_file_contents` tools registered, with no errors. | Old Name | New Name | |----------|----------| +| `add_project_item` | `projects_write` | | `cancel_workflow_run` | `actions_run_trigger` | +| `delete_project_item` | `projects_write` | | `delete_workflow_run_logs` | `actions_run_trigger` | | `download_workflow_run_artifact` | `actions_get` | +| `get_project` | `projects_get` | +| `get_project_field` | `projects_get` | +| `get_project_item` | `projects_get` | | `get_workflow` | `actions_get` | | `get_workflow_job` | `actions_get` | | `get_workflow_job_logs` | `actions_get` | | `get_workflow_run` | `actions_get` | | `get_workflow_run_logs` | `actions_get` | | `get_workflow_run_usage` | `actions_get` | +| `list_project_fields` | `projects_list` | +| `list_project_items` | `projects_list` | +| `list_projects` | `projects_list` | | `list_workflow_jobs` | `actions_list` | | `list_workflow_run_artifacts` | `actions_list` | | `list_workflow_runs` | `actions_list` | @@ -62,4 +70,5 @@ Will get `issue_read` and `get_file_contents` tools registered, with no errors. | `rerun_failed_jobs` | `actions_run_trigger` | | `rerun_workflow_run` | `actions_run_trigger` | | `run_workflow` | `actions_run_trigger` | +| `update_project_item` | `projects_write` | diff --git a/e2e.test b/e2e.test deleted file mode 100755 index 58505b3a2..000000000 Binary files a/e2e.test and /dev/null differ diff --git a/go.mod b/go.mod index 691a949bd..10bbde9d1 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( github.com/google/jsonschema-go v0.4.2 github.com/josephburnett/jd v1.9.2 github.com/microcosm-cc/bluemonday v1.0.27 - github.com/migueleliasweb/go-github-mock v1.3.0 github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 @@ -16,27 +15,17 @@ require ( require ( github.com/aymerick/douceur v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/swag v0.21.1 // indirect - github.com/google/go-github/v71 v71.0.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 + github.com/google/go-querystring v1.1.0 // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/gorilla/mux v1.8.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/lithammer/fuzzysearch v1.1.8 github.com/mailru/easyjson v0.7.7 // indirect - github.com/stretchr/objx v0.5.2 // indirect - github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/net v0.38.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect -) - -require ( - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 - github.com/google/go-querystring v1.1.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/modelcontextprotocol/go-sdk v1.2.0 github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -48,12 +37,17 @@ require ( github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 + github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 + github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/net v0.38.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.28.0 // indirect - golang.org/x/time v0.5.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6f38bea2f..b364f2ef3 100644 --- a/go.sum +++ b/go.sum @@ -15,15 +15,13 @@ github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34 github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.21.1 h1:wm0rhTb5z7qpJRHBdPOMuY4QjVUMbF6/kwoYeRAOrKU= github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30= -github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M= github.com/google/go-github/v79 v79.0.0 h1:MdodQojuFPBhmtwHiBcIGLw/e/wei2PvFX9ndxK0X4Y= github.com/google/go-github/v79 v79.0.0/go.mod h1:OAFbNhq7fQwohojb06iIIQAB9CBGYLq999myfUFnrS4= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -32,8 +30,6 @@ github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbc github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josephburnett/jd v1.9.2 h1:ECJRRFXCCqbtidkAHckHGSZm/JIaAxS1gygHLF8MI5Y= @@ -48,6 +44,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= +github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= @@ -55,8 +53,6 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/migueleliasweb/go-github-mock v1.3.0 h1:2sVP9JEMB2ubQw1IKto3/fzF51oFC6eVWOOFDgQoq88= -github.com/migueleliasweb/go-github-mock v1.3.0/go.mod h1:ipQhV8fTcj/G6m7BKzin08GaJ/3B5/SonRAkgrk0zCY= github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g= @@ -102,22 +98,51 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 9859e2e9b..b6e744d3a 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -19,6 +19,7 @@ import ( "github.com/github/github-mcp-server/pkg/lockdown" mcplog "github.com/github/github-mcp-server/pkg/log" "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v79/github" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -63,10 +64,18 @@ type MCPServerConfig struct { // LockdownMode indicates if we should enable lockdown mode LockdownMode bool + // InsidersMode indicates if we should enable experimental features + InsidersMode bool + // Logger is used for logging within the server Logger *slog.Logger // RepoAccessTTL overrides the default TTL for repository access cache entries. RepoAccessTTL *time.Duration + + // TokenScopes contains the OAuth scopes available to the token. + // When non-nil, tools requiring scopes not in this list will be hidden. + // This is used for PAT scope filtering where we can't issue scope challenges. + TokenScopes []string } // githubClients holds all the GitHub API clients created for a server instance. @@ -90,8 +99,10 @@ func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients, // We use NewEnterpriseClient unconditionally since we already parsed the API host gqlHTTPClient := &http.Client{ Transport: &bearerAuthTransport{ - transport: http.DefaultTransport, - token: cfg.Token, + transport: &github.GraphQLFeaturesTransport{ + Transport: http.DefaultTransport, + }, + token: cfg.Token, }, } gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient) @@ -160,16 +171,31 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { enabledToolsets := resolveEnabledToolsets(cfg) - // For instruction generation, we need actual toolset names (not nil). - // nil means "use defaults" in inventory, so expand it for instructions. - instructionToolsets := enabledToolsets - if instructionToolsets == nil { - instructionToolsets = github.GetDefaultToolsetIDs() + // Create feature checker + featureChecker := createFeatureChecker(cfg.EnabledFeatures) + + // Build and register the tool/resource/prompt inventory + inventoryBuilder := github.NewInventory(cfg.Translator). + WithDeprecatedAliases(github.DeprecatedToolAliases). + WithReadOnly(cfg.ReadOnly). + WithToolsets(enabledToolsets). + WithTools(cfg.EnabledTools). + WithFeatureChecker(featureChecker). + WithServerInstructions() + + // Apply token scope filtering if scopes are known (for PAT filtering) + if cfg.TokenScopes != nil { + inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes)) + } + + inventory, err := inventoryBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build inventory: %w", err) } // Create the MCP server serverOpts := &mcp.ServerOptions{ - Instructions: github.GenerateInstructions(instructionToolsets), + Instructions: inventory.Instructions(), Logger: cfg.Logger, CompletionHandler: github.CompletionsHandler(func(_ context.Context) (*gogithub.Client, error) { return clients.rest, nil @@ -199,8 +225,12 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { clients.raw, clients.repoAccess, cfg.Translator, - github.FeatureFlags{LockdownMode: cfg.LockdownMode}, + github.FeatureFlags{ + LockdownMode: cfg.LockdownMode, + InsidersMode: cfg.InsidersMode, + }, cfg.ContentWindowSize, + featureChecker, ) // Inject dependencies into context for all tool handlers @@ -210,15 +240,6 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { } }) - // Build and register the tool/resource/prompt inventory - inventory := github.NewInventory(cfg.Translator). - WithDeprecatedAliases(github.DeprecatedToolAliases). - WithReadOnly(cfg.ReadOnly). - WithToolsets(enabledToolsets). - WithTools(github.CleanTools(cfg.EnabledTools)). - WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)). - Build() - if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 { fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", ")) } @@ -310,6 +331,9 @@ type StdioServerConfig struct { // LockdownMode indicates if we should enable lockdown mode LockdownMode bool + // InsidersMode indicates if we should enable experimental features + InsidersMode bool + // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. RepoAccessCacheTTL *time.Duration } @@ -338,6 +362,22 @@ func RunStdioServer(cfg StdioServerConfig) error { logger := slog.New(slogHandler) logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) + // Fetch token scopes for scope-based tool filtering (PAT tokens only) + // Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header. + // Fine-grained PATs and other token types don't support this, so we skip filtering. + var tokenScopes []string + if strings.HasPrefix(cfg.Token, "ghp_") { + fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) + if err != nil { + logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) + } else { + tokenScopes = fetchedScopes + logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) + } + } else { + logger.Debug("skipping scope filtering for non-PAT token") + } + ghServer, err := NewMCPServer(MCPServerConfig{ Version: cfg.Version, Host: cfg.Host, @@ -350,8 +390,10 @@ func RunStdioServer(cfg StdioServerConfig) error { Translator: t, ContentWindowSize: cfg.ContentWindowSize, LockdownMode: cfg.LockdownMode, + InsidersMode: cfg.InsidersMode, Logger: logger, RepoAccessTTL: cfg.RepoAccessCacheTTL, + TokenScopes: tokenScopes, }) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) @@ -455,7 +497,7 @@ func newGHECHost(hostname string) (apiHost, error) { return apiHost{}, fmt.Errorf("failed to parse GHEC GraphQL URL: %w", err) } - uploadURL, err := url.Parse(fmt.Sprintf("https://uploads.%s", u.Hostname())) + uploadURL, err := url.Parse(fmt.Sprintf("https://uploads.%s/", u.Hostname())) if err != nil { return apiHost{}, fmt.Errorf("failed to parse GHEC Upload URL: %w", err) } @@ -636,3 +678,18 @@ func addUserAgentsMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, g } } } + +// fetchTokenScopesForHost fetches the OAuth scopes for a token from the GitHub API. +// It constructs the appropriate API host URL based on the configured host. +func fetchTokenScopesForHost(ctx context.Context, token, host string) ([]string, error) { + apiHost, err := parseAPIHost(host) + if err != nil { + return nil, fmt.Errorf("failed to parse API host: %w", err) + } + + fetcher := scopes.NewFetcher(scopes.FetcherOptions{ + APIHost: apiHost.baseRESTURL.String(), + }) + + return fetcher.FetchTokenScopes(ctx, token) +} diff --git a/internal/ghmcp/server_test.go b/internal/ghmcp/server_test.go index 04c0989d4..2139aa280 100644 --- a/internal/ghmcp/server_test.go +++ b/internal/ghmcp/server_test.go @@ -23,6 +23,7 @@ func TestNewMCPServer_CreatesSuccessfully(t *testing.T) { Translator: translations.NullTranslationHelper, ContentWindowSize: 5000, LockdownMode: false, + InsidersMode: false, } // Create the server diff --git a/internal/toolsnaps/toolsnaps.go b/internal/toolsnaps/toolsnaps.go index 89d02e1ee..4b25904ed 100644 --- a/internal/toolsnaps/toolsnaps.go +++ b/internal/toolsnaps/toolsnaps.go @@ -67,15 +67,35 @@ func Test(toolName string, tool any) error { } func writeSnap(snapPath string, contents []byte) error { + // Sort the JSON keys recursively to ensure consistent output. + // We do this by unmarshaling and remarshaling, which ensures Go's JSON encoder + // sorts all map keys alphabetically at every level. + sortedJSON, err := sortJSONKeys(contents) + if err != nil { + return fmt.Errorf("failed to sort JSON keys: %w", err) + } + // Ensure the directory exists if err := os.MkdirAll(filepath.Dir(snapPath), 0700); err != nil { return fmt.Errorf("failed to create snapshot directory: %w", err) } // Write the snapshot file - if err := os.WriteFile(snapPath, contents, 0600); err != nil { + if err := os.WriteFile(snapPath, sortedJSON, 0600); err != nil { return fmt.Errorf("failed to write snapshot file: %w", err) } return nil } + +// sortJSONKeys recursively sorts all object keys in a JSON byte array by +// unmarshaling to map[string]any and remarshaling. Go's JSON encoder +// automatically sorts map keys alphabetically. +func sortJSONKeys(jsonData []byte) ([]byte, error) { + var data any + if err := json.Unmarshal(jsonData, &data); err != nil { + return nil, err + } + + return json.MarshalIndent(data, "", " ") +} diff --git a/internal/toolsnaps/toolsnaps_test.go b/internal/toolsnaps/toolsnaps_test.go index be9cadf7f..c7d7301bc 100644 --- a/internal/toolsnaps/toolsnaps_test.go +++ b/internal/toolsnaps/toolsnaps_test.go @@ -131,3 +131,184 @@ func TestMalformedSnapshotJSON(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "failed to parse snapshot JSON for dummy", "expected error about malformed snapshot JSON") } + +func TestSortJSONKeys(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple object", + input: `{"z": 1, "a": 2, "m": 3}`, + expected: "{\n \"a\": 2,\n \"m\": 3,\n \"z\": 1\n}", + }, + { + name: "nested object", + input: `{"z": {"y": 1, "x": 2}, "a": 3}`, + expected: "{\n \"a\": 3,\n \"z\": {\n \"x\": 2,\n \"y\": 1\n }\n}", + }, + { + name: "array with objects", + input: `{"items": [{"z": 1, "a": 2}, {"y": 3, "b": 4}]}`, + expected: "{\n \"items\": [\n {\n \"a\": 2,\n \"z\": 1\n },\n {\n \"b\": 4,\n \"y\": 3\n }\n ]\n}", + }, + { + name: "deeply nested", + input: `{"z": {"y": {"x": 1, "a": 2}, "b": 3}, "m": 4}`, + expected: "{\n \"m\": 4,\n \"z\": {\n \"b\": 3,\n \"y\": {\n \"a\": 2,\n \"x\": 1\n }\n }\n}", + }, + { + name: "properties field like in toolsnaps", + input: `{"name": "test", "properties": {"repo": {"type": "string"}, "owner": {"type": "string"}, "page": {"type": "number"}}}`, + expected: "{\n \"name\": \"test\",\n \"properties\": {\n \"owner\": {\n \"type\": \"string\"\n },\n \"page\": {\n \"type\": \"number\"\n },\n \"repo\": {\n \"type\": \"string\"\n }\n }\n}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := sortJSONKeys([]byte(tt.input)) + require.NoError(t, err) + assert.Equal(t, tt.expected, string(result)) + }) + } +} + +func TestSortJSONKeysIdempotent(t *testing.T) { + // Given a JSON string that's already sorted + input := `{"a": 1, "b": {"x": 2, "y": 3}, "c": [{"m": 4, "n": 5}]}` + + // When we sort it once + sorted1, err := sortJSONKeys([]byte(input)) + require.NoError(t, err) + + // And sort it again + sorted2, err := sortJSONKeys(sorted1) + require.NoError(t, err) + + // Then the results should be identical + assert.Equal(t, string(sorted1), string(sorted2)) +} + +func TestToolSnapKeysSorted(t *testing.T) { + withIsolatedWorkingDir(t) + + // Given a tool with fields that could be in any order + type complexTool struct { + Name string `json:"name"` + Description string `json:"description"` + Properties map[string]interface{} `json:"properties"` + Annotations map[string]interface{} `json:"annotations"` + } + + tool := complexTool{ + Name: "test_tool", + Description: "A test tool", + Properties: map[string]interface{}{ + "zzz": "last", + "aaa": "first", + "mmm": "middle", + "owner": map[string]interface{}{"type": "string", "description": "Owner"}, + "repo": map[string]interface{}{"type": "string", "description": "Repo"}, + }, + Annotations: map[string]interface{}{ + "readOnly": true, + "title": "Test", + }, + } + + // When we write the snapshot + t.Setenv("UPDATE_TOOLSNAPS", "true") + err := Test("complex", tool) + require.NoError(t, err) + + // Then the snapshot file should have sorted keys + snapJSON, err := os.ReadFile("__toolsnaps__/complex.snap") + require.NoError(t, err) + + // Verify that the JSON is properly sorted by checking key order + var parsed map[string]interface{} + err = json.Unmarshal(snapJSON, &parsed) + require.NoError(t, err) + + // Check that properties are sorted + propsJSON, _ := json.MarshalIndent(parsed["properties"], "", " ") + propsStr := string(propsJSON) + // The properties should have "aaa" before "mmm" before "zzz" + aaaIndex := -1 + mmmIndex := -1 + zzzIndex := -1 + for i, line := range propsStr { + if line == 'a' && i+2 < len(propsStr) && propsStr[i:i+3] == "aaa" { + aaaIndex = i + } + if line == 'm' && i+2 < len(propsStr) && propsStr[i:i+3] == "mmm" { + mmmIndex = i + } + if line == 'z' && i+2 < len(propsStr) && propsStr[i:i+3] == "zzz" { + zzzIndex = i + } + } + assert.Greater(t, mmmIndex, aaaIndex, "mmm should come after aaa") + assert.Greater(t, zzzIndex, mmmIndex, "zzz should come after mmm") +} + +func TestStructFieldOrderingSortedAlphabetically(t *testing.T) { + withIsolatedWorkingDir(t) + + // Given a struct with fields defined in non-alphabetical order + // This test ensures that struct field order doesn't affect the JSON output + type toolWithNonAlphabeticalFields struct { + ZField string `json:"zField"` // Should appear last in JSON + AField string `json:"aField"` // Should appear first in JSON + MField string `json:"mField"` // Should appear in the middle + } + + tool := toolWithNonAlphabeticalFields{ + ZField: "z value", + AField: "a value", + MField: "m value", + } + + // When we write the snapshot + t.Setenv("UPDATE_TOOLSNAPS", "true") + err := Test("struct_field_order", tool) + require.NoError(t, err) + + // Then the snapshot file should have alphabetically sorted keys despite struct field order + snapJSON, err := os.ReadFile("__toolsnaps__/struct_field_order.snap") + require.NoError(t, err) + + snapStr := string(snapJSON) + + // Find the positions of each field in the JSON string + aFieldIndex := -1 + mFieldIndex := -1 + zFieldIndex := -1 + for i := 0; i < len(snapStr)-7; i++ { + switch snapStr[i : i+6] { + case "aField": + aFieldIndex = i + case "mField": + mFieldIndex = i + case "zField": + zFieldIndex = i + } + } + + // Verify alphabetical ordering in the JSON output + require.NotEqual(t, -1, aFieldIndex, "aField should be present") + require.NotEqual(t, -1, mFieldIndex, "mField should be present") + require.NotEqual(t, -1, zFieldIndex, "zField should be present") + assert.Less(t, aFieldIndex, mFieldIndex, "aField should appear before mField") + assert.Less(t, mFieldIndex, zFieldIndex, "mField should appear before zField") + + // Also verify idempotency - running the test again should produce identical output + err = Test("struct_field_order", tool) + require.NoError(t, err) + + snapJSON2, err := os.ReadFile("__toolsnaps__/struct_field_order.snap") + require.NoError(t, err) + + assert.Equal(t, string(snapJSON), string(snapJSON2), "Multiple runs should produce identical output") +} diff --git a/pkg/buffer/buffer.go b/pkg/buffer/buffer.go index 14bcf9582..54ed0be4d 100644 --- a/pkg/buffer/buffer.go +++ b/pkg/buffer/buffer.go @@ -1,12 +1,17 @@ package buffer import ( - "bufio" + "bytes" "fmt" + "io" "net/http" "strings" ) +// maxLineSize is the maximum size for a single log line (10MB). +// GitHub Actions logs can contain extremely long lines (base64 content, minified JS, etc.) +const maxLineSize = 10 * 1024 * 1024 + // ProcessResponseAsRingBufferToEnd reads the body of an HTTP response line by line, // storing only the last maxJobLogLines lines using a ring buffer (sliding window). // This efficiently retains the most recent lines, overwriting older ones as needed. @@ -25,6 +30,7 @@ import ( // // The function uses a ring buffer to efficiently store only the last maxJobLogLines lines. // If the response contains more lines than maxJobLogLines, only the most recent lines are kept. +// Lines exceeding maxLineSize are truncated with a marker. func ProcessResponseAsRingBufferToEnd(httpResp *http.Response, maxJobLogLines int) (string, int, *http.Response, error) { if maxJobLogLines > 100000 { maxJobLogLines = 100000 @@ -35,20 +41,74 @@ func ProcessResponseAsRingBufferToEnd(httpResp *http.Response, maxJobLogLines in totalLines := 0 writeIndex := 0 - scanner := bufio.NewScanner(httpResp.Body) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + const readBufferSize = 64 * 1024 // 64KB read buffer + const maxDisplayLength = 1000 // Keep first 1000 chars of truncated lines - for scanner.Scan() { - line := scanner.Text() - totalLines++ + readBuf := make([]byte, readBufferSize) + var currentLine strings.Builder + lineTruncated := false + // storeLine saves the current line to the ring buffer and resets state + storeLine := func() { + line := currentLine.String() + if lineTruncated && len(line) > maxDisplayLength { + line = line[:maxDisplayLength] + } + if lineTruncated { + line += "... [TRUNCATED]" + } lines[writeIndex] = line validLines[writeIndex] = true + totalLines++ writeIndex = (writeIndex + 1) % maxJobLogLines + currentLine.Reset() + lineTruncated = false } - if err := scanner.Err(); err != nil { - return "", 0, httpResp, fmt.Errorf("failed to read log content: %w", err) + // accumulate adds bytes to currentLine up to maxLineSize, sets lineTruncated if exceeded + accumulate := func(data []byte) { + if lineTruncated { + return + } + remaining := maxLineSize - currentLine.Len() + if remaining <= 0 { + lineTruncated = true + return + } + if remaining > len(data) { + remaining = len(data) + } + currentLine.Write(data[:remaining]) + if currentLine.Len() >= maxLineSize { + lineTruncated = true + } + } + + for { + n, err := httpResp.Body.Read(readBuf) + if n > 0 { + chunk := readBuf[:n] + for len(chunk) > 0 { + newlineIdx := bytes.IndexByte(chunk, '\n') + if newlineIdx < 0 { + accumulate(chunk) + break + } + accumulate(chunk[:newlineIdx]) + storeLine() + chunk = chunk[newlineIdx+1:] + } + } + + if err == io.EOF { + if currentLine.Len() > 0 { + storeLine() + } + break + } + if err != nil { + return "", 0, httpResp, fmt.Errorf("failed to read log content: %w", err) + } } var result []string diff --git a/pkg/buffer/buffer_test.go b/pkg/buffer/buffer_test.go new file mode 100644 index 000000000..86308ec5e --- /dev/null +++ b/pkg/buffer/buffer_test.go @@ -0,0 +1,176 @@ +package buffer + +import ( + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProcessResponseAsRingBufferToEnd(t *testing.T) { + t.Run("normal lines", func(t *testing.T) { + body := "line1\nline2\nline3\n" + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(body)), + } + + result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 10) + if respOut != nil && respOut.Body != nil { + defer respOut.Body.Close() + } + require.NoError(t, err) + assert.Equal(t, 3, totalLines) + assert.Equal(t, "line1\nline2\nline3", result) + }) + + t.Run("ring buffer keeps last N lines", func(t *testing.T) { + body := "line1\nline2\nline3\nline4\nline5\n" + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(body)), + } + + result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 3) + if respOut != nil && respOut.Body != nil { + defer respOut.Body.Close() + } + require.NoError(t, err) + assert.Equal(t, 5, totalLines) + assert.Equal(t, "line3\nline4\nline5", result) + }) + + t.Run("handles very long line exceeding 10MB", func(t *testing.T) { + // Create a line that exceeds maxLineSize (10MB) + longLine := strings.Repeat("x", 11*1024*1024) // 11MB + body := "line1\n" + longLine + "\nline3\n" + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(body)), + } + + result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 100) + if respOut != nil && respOut.Body != nil { + defer respOut.Body.Close() + } + require.NoError(t, err) + // Should have processed lines with truncation marker + assert.Greater(t, totalLines, 0) + assert.Contains(t, result, "TRUNCATED") + }) + + t.Run("handles line at exactly max size", func(t *testing.T) { + // Create a line just under maxLineSize + longLine := strings.Repeat("a", 1024*1024) // 1MB - should work fine + body := "start\n" + longLine + "\nend\n" + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(body)), + } + + result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 100) + if respOut != nil && respOut.Body != nil { + defer respOut.Body.Close() + } + require.NoError(t, err) + assert.Equal(t, 3, totalLines) + assert.Contains(t, result, "start") + assert.Contains(t, result, "end") + }) + + t.Run("ring buffer with long line in middle of many lines", func(t *testing.T) { + // Create many lines with a long line in the middle + // Ring buffer size is 5, so we should only keep the last 5 lines + var sb strings.Builder + for i := 1; i <= 10; i++ { + sb.WriteString(fmt.Sprintf("line%d\n", i)) + } + // Insert an 11MB line (exceeds maxLineSize of 10MB) + longLine := strings.Repeat("x", 11*1024*1024) + sb.WriteString(longLine) + sb.WriteString("\n") + for i := 11; i <= 20; i++ { + sb.WriteString(fmt.Sprintf("line%d\n", i)) + } + + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(sb.String())), + } + + result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 5) + if respOut != nil && respOut.Body != nil { + defer respOut.Body.Close() + } + require.NoError(t, err) + // 10 lines before + 1 long line + 10 lines after = 21 total + assert.Equal(t, 21, totalLines) + // Should only have the last 5 lines (line16 through line20) + assert.Contains(t, result, "line16") + assert.Contains(t, result, "line17") + assert.Contains(t, result, "line18") + assert.Contains(t, result, "line19") + assert.Contains(t, result, "line20") + // Should NOT contain earlier lines + assert.NotContains(t, result, "line1\n") + assert.NotContains(t, result, "line10\n") + // The truncated line should not be in the last 5 + assert.NotContains(t, result, "TRUNCATED") + }) + + t.Run("empty response body", func(t *testing.T) { + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader("")), + } + + result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 10) + if respOut != nil && respOut.Body != nil { + defer respOut.Body.Close() + } + require.NoError(t, err) + assert.Equal(t, 0, totalLines) + assert.Equal(t, "", result) + }) + + t.Run("line at exactly maxLineSize boundary", func(t *testing.T) { + // Create a line at exactly maxLineSize (10MB) - should be truncated + exactLine := strings.Repeat("z", 10*1024*1024) + body := "before\n" + exactLine + "\nafter\n" + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(body)), + } + + result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 10) + if respOut != nil && respOut.Body != nil { + defer respOut.Body.Close() + } + require.NoError(t, err) + assert.Equal(t, 3, totalLines) + assert.Contains(t, result, "before") + assert.Contains(t, result, "TRUNCATED") + assert.Contains(t, result, "after") + }) + + t.Run("ring buffer keeps truncated line when in last N", func(t *testing.T) { + // Long line followed by only 2 more lines, with ring buffer size 5 + longLine := strings.Repeat("y", 11*1024*1024) + body := "line1\nline2\nline3\n" + longLine + "\nlineA\nlineB\n" + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(body)), + } + + result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 5) + if respOut != nil && respOut.Body != nil { + defer respOut.Body.Close() + } + require.NoError(t, err) + assert.Equal(t, 6, totalLines) + // Last 5: line2, line3, truncated, lineA, lineB + assert.Contains(t, result, "line2") + assert.Contains(t, result, "line3") + assert.Contains(t, result, "TRUNCATED") + assert.Contains(t, result, "lineA") + assert.Contains(t, result, "lineB") + // line1 should be rotated out + assert.NotContains(t, result, "line1") + }) +} diff --git a/pkg/errors/error.go b/pkg/errors/error.go index d17fedd92..93ea852a8 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -120,6 +120,14 @@ func NewGitHubAPIErrorToCtx(ctx context.Context, message string, resp *github.Re return ctx, nil } +func NewGitHubGraphQLErrorToCtx(ctx context.Context, message string, err error) (context.Context, error) { + graphQLErr := newGitHubGraphQLError(message, err) + if ctx != nil { + _, _ = addGitHubGraphQLErrorToContext(ctx, graphQLErr) // Explicitly ignore error for graceful handling + } + return ctx, nil +} + func addGitHubAPIErrorToContext(ctx context.Context, err *GitHubAPIError) (context.Context, error) { if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok { val.api = append(val.api, err) // append the error to the existing slice in the context diff --git a/pkg/github/__toolsnaps__/actions_get.snap b/pkg/github/__toolsnaps__/actions_get.snap index b5f3b85bd..ba128875e 100644 --- a/pkg/github/__toolsnaps__/actions_get.snap +++ b/pkg/github/__toolsnaps__/actions_get.snap @@ -5,16 +5,8 @@ }, "description": "Get details about specific GitHub Actions resources.\nUse this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs.\n", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner", - "repo", - "resource_id" - ], "properties": { "method": { - "type": "string", "description": "The method to execute", "enum": [ "get_workflow", @@ -23,21 +15,29 @@ "download_workflow_run_artifact", "get_workflow_run_usage", "get_workflow_run_logs_url" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "resource_id": { - "type": "string", - "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method.\n- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods.\n- Provide an artifact ID for 'download_workflow_run_artifact' method.\n- Provide a job ID for 'get_workflow_job' method.\n" + "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method.\n- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods.\n- Provide an artifact ID for 'download_workflow_run_artifact' method.\n- Provide a job ID for 'get_workflow_job' method.\n", + "type": "string" } - } + }, + "required": [ + "method", + "owner", + "repo", + "resource_id" + ], + "type": "object" }, "name": "actions_get" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/actions_list.snap b/pkg/github/__toolsnaps__/actions_list.snap index 3968a6eae..a7e9ec56b 100644 --- a/pkg/github/__toolsnaps__/actions_list.snap +++ b/pkg/github/__toolsnaps__/actions_list.snap @@ -5,74 +5,66 @@ }, "description": "Tools for listing GitHub Actions resources.\nUse this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run.\n", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner", - "repo" - ], "properties": { "method": { - "type": "string", "description": "The action to perform", "enum": [ "list_workflows", "list_workflow_runs", "list_workflow_jobs", "list_workflow_run_artifacts" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (default: 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "per_page": { - "type": "number", "description": "Results per page for pagination (default: 30, max: 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "resource_id": { - "type": "string", - "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Do not provide any resource ID for 'list_workflows' method.\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method.\n- Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods.\n" + "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Do not provide any resource ID for 'list_workflows' method.\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method, or omit to list all workflow runs in the repository.\n- Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods.\n", + "type": "string" }, "workflow_jobs_filter": { - "type": "object", "description": "Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs'", "properties": { "filter": { - "type": "string", "description": "Filters jobs by their completed_at timestamp", "enum": [ "latest", "all" - ] + ], + "type": "string" } - } + }, + "type": "object" }, "workflow_runs_filter": { - "type": "object", "description": "Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs'", "properties": { "actor": { - "type": "string", - "description": "Filter to a specific GitHub user's workflow runs." + "description": "Filter to a specific GitHub user's workflow runs.", + "type": "string" }, "branch": { - "type": "string", - "description": "Filter workflow runs to a specific Git branch. Use the name of the branch." + "description": "Filter workflow runs to a specific Git branch. Use the name of the branch.", + "type": "string" }, "event": { - "type": "string", "description": "Filter workflow runs to a specific event type", "enum": [ "branch_protection_rule", @@ -107,10 +99,10 @@ "workflow_call", "workflow_dispatch", "workflow_run" - ] + ], + "type": "string" }, "status": { - "type": "string", "description": "Filter workflow runs to only runs with a specific status", "enum": [ "queued", @@ -118,11 +110,19 @@ "completed", "requested", "waiting" - ] + ], + "type": "string" } - } + }, + "type": "object" } - } + }, + "required": [ + "method", + "owner", + "repo" + ], + "type": "object" }, "name": "actions_list" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/actions_run_trigger.snap b/pkg/github/__toolsnaps__/actions_run_trigger.snap index 4e16f8958..c51501c17 100644 --- a/pkg/github/__toolsnaps__/actions_run_trigger.snap +++ b/pkg/github/__toolsnaps__/actions_run_trigger.snap @@ -5,19 +5,12 @@ }, "description": "Trigger GitHub Actions workflow operations, including running, re-running, cancelling workflow runs, and deleting workflow run logs.", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner", - "repo" - ], "properties": { "inputs": { - "type": "object", - "description": "Inputs the workflow accepts. Only used for 'run_workflow' method." + "description": "Inputs the workflow accepts. Only used for 'run_workflow' method.", + "type": "object" }, "method": { - "type": "string", "description": "The method to execute", "enum": [ "run_workflow", @@ -25,29 +18,36 @@ "rerun_failed_jobs", "cancel_workflow_run", "delete_workflow_run_logs" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "ref": { - "type": "string", - "description": "The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method." + "description": "The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method.", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "run_id": { - "type": "number", - "description": "The ID of the workflow run. Required for all methods except 'run_workflow'." + "description": "The ID of the workflow run. Required for all methods except 'run_workflow'.", + "type": "number" }, "workflow_id": { - "type": "string", - "description": "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method." + "description": "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method.", + "type": "string" } - } + }, + "required": [ + "method", + "owner", + "repo" + ], + "type": "object" }, "name": "actions_run_trigger" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap b/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap index 78795c096..af4c41f52 100644 --- a/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap +++ b/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap @@ -4,69 +4,69 @@ }, "description": "Add review comment to the requester's latest pending pull request review. A pending review needs to already exist to call this (check with the user if not sure).", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "pullNumber", - "path", - "body", - "subjectType" - ], "properties": { "body": { - "type": "string", - "description": "The text of the review comment" + "description": "The text of the review comment", + "type": "string" }, "line": { - "type": "number", - "description": "The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range" + "description": "The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range", + "type": "number" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "path": { - "type": "string", - "description": "The relative path to the file that necessitates a comment" + "description": "The relative path to the file that necessitates a comment", + "type": "string" }, "pullNumber": { - "type": "number", - "description": "Pull request number" + "description": "Pull request number", + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "side": { - "type": "string", "description": "The side of the diff to comment on. LEFT indicates the previous state, RIGHT indicates the new state", "enum": [ "LEFT", "RIGHT" - ] + ], + "type": "string" }, "startLine": { - "type": "number", - "description": "For multi-line comments, the first line of the range that the comment applies to" + "description": "For multi-line comments, the first line of the range that the comment applies to", + "type": "number" }, "startSide": { - "type": "string", "description": "For multi-line comments, the starting side of the diff that the comment applies to. LEFT indicates the previous state, RIGHT indicates the new state", "enum": [ "LEFT", "RIGHT" - ] + ], + "type": "string" }, "subjectType": { - "type": "string", "description": "The level at which the comment is targeted", "enum": [ "FILE", "LINE" - ] + ], + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "pullNumber", + "path", + "body", + "subjectType" + ], + "type": "object" }, "name": "add_comment_to_pending_review" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_issue_comment.snap b/pkg/github/__toolsnaps__/add_issue_comment.snap index fb2a9e7b3..d273a582d 100644 --- a/pkg/github/__toolsnaps__/add_issue_comment.snap +++ b/pkg/github/__toolsnaps__/add_issue_comment.snap @@ -4,31 +4,31 @@ }, "description": "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "issue_number", - "body" - ], "properties": { "body": { - "type": "string", - "description": "Comment content" + "description": "Comment content", + "type": "string" }, "issue_number": { - "type": "number", - "description": "Issue number to comment on" + "description": "Issue number to comment on", + "type": "number" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "issue_number", + "body" + ], + "type": "object" }, "name": "add_issue_comment" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_project_item.snap b/pkg/github/__toolsnaps__/add_project_item.snap index 08f495370..e6a5cc3c4 100644 --- a/pkg/github/__toolsnaps__/add_project_item.snap +++ b/pkg/github/__toolsnaps__/add_project_item.snap @@ -4,44 +4,44 @@ }, "description": "Add a specific Project item for a user or org", "inputSchema": { - "type": "object", - "required": [ - "owner_type", - "owner", - "project_number", - "item_type", - "item_id" - ], "properties": { "item_id": { - "type": "number", - "description": "The numeric ID of the issue or pull request to add to the project." + "description": "The numeric ID of the issue or pull request to add to the project.", + "type": "number" }, "item_type": { - "type": "string", "description": "The item's type, either issue or pull_request.", "enum": [ "issue", "pull_request" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." + "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", + "type": "string" }, "owner_type": { - "type": "string", "description": "Owner type", "enum": [ "user", "org" - ] + ], + "type": "string" }, "project_number": { - "type": "number", - "description": "The project's number." + "description": "The project's number.", + "type": "number" } - } + }, + "required": [ + "owner_type", + "owner", + "project_number", + "item_type", + "item_id" + ], + "type": "object" }, "name": "add_project_item" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap index 22c380055..9c105267b 100644 --- a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap +++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap @@ -4,39 +4,47 @@ "title": "Assign Copilot to issue" }, "description": "Assign Copilot to a specific issue in a GitHub repository.\n\nThis tool can help with the following outcomes:\n- a Pull Request created with source code changes to resolve the issue\n\n\nMore information can be found at:\n- https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot\n", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "issueNumber" - ], - "properties": { - "issueNumber": { - "type": "number", - "description": "Issue number" - }, - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - } - } - }, - "name": "assign_copilot_to_issue", "icons": [ { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC20lEQVRIidWUS4wMURSGv3O7kWmPEMRrSMzcbl1dpqtmGuOxsCKECCKxEBusSJhIWEhsWLFAbC1sWFiISBARCyQ2kzSZGaMxHokgXvGIiMH0PRZjpJqqHpb+TeX+59z//H/q5sD/DqlX9H1/zFeX2qzIKoFWYDKgwBtUymL0UkNaT3V3d3/+5wG2EGxB9TDIxGFMvhVhb9/drpN/NaDJC7MGdwJk6TDCv0Gvq0lve9R762GUNdFDLleaZNBrICGq+4yhvf9TJtP/KZNB2PrLlbBliBfRhajuAwnFVa/n8/nkxFkv3GO9oJrzgwVxdesV71ov6I2r5fxggfWCatYL9yYmUJgLPH7Q29WZ4OED6Me4wuAdeQK6MMqna9t0GuibBHFAmgZ9JMG9BhkXZWoSCDSATIq7aguBD0wBplq/tZBgYDIwKnZAs99mFRYD9vd/YK0dpcqhobM6d9haWyOULRTbAauwuNlvsxHTYP3iBnVyXGAa8BIYC3oVeAKioCtAPEE7FCOgR0ErIJdBBZgNskzh40+NF6K6s+9e91lp9osrxMnFoTSmSmPVsF+E5cB0YEDgtoMjjypd5wCy+WC9GnajhEAa4bkqV9LOHKwa9/yneYeyUqwX3AdyQ5EeVrrqro/hYL0g+ggemKh4HGbPmVu0+fB8U76lpR6XgJwZpoGUpNYiusZg1tXjkmCAav0OMTXfJC4eVYPqwbot6l4BCPqyLhd7lwMAWC/cYb3gi/UCzRaKOxsbFzVEM1iv2Ebt5v2Dm14qZbJecZf1Ah3UCrcTbbB+awHnjgHLgHeinHYqZ8aPSXWWy+XvcQZLpdKI9/0D7UbZiLIJmABckVSqo+/OrUrNgF+D8q1LEdcBrAJGAJ8ROlGeicorABWdAswE5gOjge8CF8Ad66v03IjqJb75WS0tE0YOmNWqLBGReaAzgIkMLrt3oM9UpSzCzW9pd+FpT8/7JK3/Gz8Ao5X6wtwP7N4AAAAASUVORK5CYII=", "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC20lEQVRIidWUS4wMURSGv3O7kWmPEMRrSMzcbl1dpqtmGuOxsCKECCKxEBusSJhIWEhsWLFAbC1sWFiISBARCyQ2kzSZGaMxHokgXvGIiMH0PRZjpJqqHpb+TeX+59z//H/q5sD/DqlX9H1/zFeX2qzIKoFWYDKgwBtUymL0UkNaT3V3d3/+5wG2EGxB9TDIxGFMvhVhb9/drpN/NaDJC7MGdwJk6TDCv0Gvq0lve9R762GUNdFDLleaZNBrICGq+4yhvf9TJtP/KZNB2PrLlbBliBfRhajuAwnFVa/n8/nkxFkv3GO9oJrzgwVxdesV71ov6I2r5fxggfWCatYL9yYmUJgLPH7Q29WZ4OED6Me4wuAdeQK6MMqna9t0GuibBHFAmgZ9JMG9BhkXZWoSCDSATIq7aguBD0wBplq/tZBgYDIwKnZAs99mFRYD9vd/YK0dpcqhobM6d9haWyOULRTbAauwuNlvsxHTYP3iBnVyXGAa8BIYC3oVeAKioCtAPEE7FCOgR0ErIJdBBZgNskzh40+NF6K6s+9e91lp9osrxMnFoTSmSmPVsF+E5cB0YEDgtoMjjypd5wCy+WC9GnajhEAa4bkqV9LOHKwa9/yneYeyUqwX3AdyQ5EeVrrqro/hYL0g+ggemKh4HGbPmVu0+fB8U76lpR6XgJwZpoGUpNYiusZg1tXjkmCAav0OMTXfJC4eVYPqwbot6l4BCPqyLhd7lwMAWC/cYb3gi/UCzRaKOxsbFzVEM1iv2Ebt5v2Dm14qZbJecZf1Ah3UCrcTbbB+awHnjgHLgHeinHYqZ8aPSXWWy+XvcQZLpdKI9/0D7UbZiLIJmABckVSqo+/OrUrNgF+D8q1LEdcBrAJGAJ8ROlGeicorABWdAswE5gOjge8CF8Ad66v03IjqJb75WS0tE0YOmNWqLBGReaAzgIkMLrt3oM9UpSzCzW9pd+FpT8/7JK3/Gz8Ao5X6wtwP7N4AAAAASUVORK5CYII=", "theme": "light" }, { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACCElEQVRIid2UPWsUYRSFn3dxWWJUkESiBgslFokfhehGiGClBBQx4h9IGlEh2ijYxh+gxEL/hIWwhYpF8KNZsFRJYdJEiUbjCkqisj4W+y6Mk5nd1U4PDMOce+45L3fmDvzXUDeo59WK+kb9rn5TF9R76jm1+2/NJ9QPtseSOv4nxrvVmQ6M05hRB9qZ98ZR1NRralntitdEwmw8wQ9HbS329rQKuKLW1XJO/aX6IqdWjr1Xk/y6lG4vMBdCqOacoZZ3uBBCVZ0HDrcK2AYs5ZkAuwBb1N8Dm5JEISXoAnqzOtU9QB+wVR3KCdgClDIr6kCc4c/0O1BLNnahiYpaSmmGY62e/JpCLJ4FpmmMaBHYCDwC5mmMZBQYBC7HnhvAK+B+fN4JHAM+R4+3wGQI4S7qaExtol+9o86pq+oX9Yk6ljjtGfVprK2qr9Xb6vaET109jjqb3Jac2XaM1PLNpok1Aep+G/+dfa24nADTX1EWTgOngLE2XCYKQL0DTfKex2WhXgCutxG9i/fFNlwWpgBQL6orcWyTaldToRbUA2pow61XL0WPFfXCb1HqkPowCj6q0+qIWsw7nlpUj6i31OXY+0AdbGpCRtNRGgt1AigCX4EqsJAYTR+wAzgEdAM/gApwM4TwOOm3JiARtBk4CYwAB4F+oIfGZi/HwOfAM6ASQviU5/Vv4xcBzmW2eT1nrQAAAABJRU5ErkJggg==", "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACCElEQVRIid2UPWsUYRSFn3dxWWJUkESiBgslFokfhehGiGClBBQx4h9IGlEh2ijYxh+gxEL/hIWwhYpF8KNZsFRJYdJEiUbjCkqisj4W+y6Mk5nd1U4PDMOce+45L3fmDvzXUDeo59WK+kb9rn5TF9R76jm1+2/NJ9QPtseSOv4nxrvVmQ6M05hRB9qZ98ZR1NRralntitdEwmw8wQ9HbS329rQKuKLW1XJO/aX6IqdWjr1Xk/y6lG4vMBdCqOacoZZ3uBBCVZ0HDrcK2AYs5ZkAuwBb1N8Dm5JEISXoAnqzOtU9QB+wVR3KCdgClDIr6kCc4c/0O1BLNnahiYpaSmmGY62e/JpCLJ4FpmmMaBHYCDwC5mmMZBQYBC7HnhvAK+B+fN4JHAM+R4+3wGQI4S7qaExtol+9o86pq+oX9Yk6ljjtGfVprK2qr9Xb6vaET109jjqb3Jac2XaM1PLNpok1Aep+G/+dfa24nADTX1EWTgOngLE2XCYKQL0DTfKex2WhXgCutxG9i/fFNlwWpgBQL6orcWyTaldToRbUA2pow61XL0WPFfXCb1HqkPowCj6q0+qIWsw7nlpUj6i31OXY+0AdbGpCRtNRGgt1AigCX4EqsJAYTR+wAzgEdAM/gApwM4TwOOm3JiARtBk4CYwAB4F+oIfGZi/HwOfAM6ASQviU5/Vv4xcBzmW2eT1nrQAAAABJRU5ErkJggg==", "theme": "dark" } - ] + ], + "inputSchema": { + "properties": { + "base_ref": { + "description": "Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch", + "type": "string" + }, + "custom_instructions": { + "description": "Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description", + "type": "string" + }, + "issue_number": { + "description": "Issue number", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "issue_number" + ], + "type": "object" + }, + "name": "assign_copilot_to_issue" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_branch.snap b/pkg/github/__toolsnaps__/create_branch.snap index 675a2de9c..a561247ef 100644 --- a/pkg/github/__toolsnaps__/create_branch.snap +++ b/pkg/github/__toolsnaps__/create_branch.snap @@ -4,30 +4,30 @@ }, "description": "Create a new branch in a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "branch" - ], "properties": { "branch": { - "type": "string", - "description": "Name for new branch" + "description": "Name for new branch", + "type": "string" }, "from_branch": { - "type": "string", - "description": "Source branch (defaults to repo default)" + "description": "Source branch (defaults to repo default)", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "branch" + ], + "type": "object" }, "name": "create_branch" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_gist.snap b/pkg/github/__toolsnaps__/create_gist.snap index 465206ab4..0ef05aa4a 100644 --- a/pkg/github/__toolsnaps__/create_gist.snap +++ b/pkg/github/__toolsnaps__/create_gist.snap @@ -4,30 +4,30 @@ }, "description": "Create a new gist", "inputSchema": { - "type": "object", - "required": [ - "filename", - "content" - ], "properties": { "content": { - "type": "string", - "description": "Content for simple single-file gist creation" + "description": "Content for simple single-file gist creation", + "type": "string" }, "description": { - "type": "string", - "description": "Description of the gist" + "description": "Description of the gist", + "type": "string" }, "filename": { - "type": "string", - "description": "Filename for simple single-file gist creation" + "description": "Filename for simple single-file gist creation", + "type": "string" }, "public": { - "type": "boolean", + "default": false, "description": "Whether the gist is public", - "default": false + "type": "boolean" } - } + }, + "required": [ + "filename", + "content" + ], + "type": "object" }, "name": "create_gist" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_or_update_file.snap b/pkg/github/__toolsnaps__/create_or_update_file.snap index 2d9ae1144..9d28c8085 100644 --- a/pkg/github/__toolsnaps__/create_or_update_file.snap +++ b/pkg/github/__toolsnaps__/create_or_update_file.snap @@ -4,45 +4,45 @@ }, "description": "Create or update a single file in a GitHub repository. \nIf 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.\n\nIn order to obtain the SHA of original file version before updating, use the following git command:\ngit ls-tree HEAD \u003cpath to file\u003e\n\nIf 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.\n", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "path", - "content", - "message", - "branch" - ], "properties": { "branch": { - "type": "string", - "description": "Branch to create/update the file in" + "description": "Branch to create/update the file in", + "type": "string" }, "content": { - "type": "string", - "description": "Content of the file" + "description": "Content of the file", + "type": "string" }, "message": { - "type": "string", - "description": "Commit message" + "description": "Commit message", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner (username or organization)" + "description": "Repository owner (username or organization)", + "type": "string" }, "path": { - "type": "string", - "description": "Path where to create/update the file" + "description": "Path where to create/update the file", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "sha": { - "type": "string", - "description": "The blob SHA of the file being replaced." + "description": "The blob SHA of the file being replaced.", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "path", + "content", + "message", + "branch" + ], + "type": "object" }, "name": "create_or_update_file" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_pull_request.snap b/pkg/github/__toolsnaps__/create_pull_request.snap index 80f0b9863..cc22897fa 100644 --- a/pkg/github/__toolsnaps__/create_pull_request.snap +++ b/pkg/github/__toolsnaps__/create_pull_request.snap @@ -4,48 +4,48 @@ }, "description": "Create a new pull request in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "title", - "head", - "base" - ], "properties": { "base": { - "type": "string", - "description": "Branch to merge into" + "description": "Branch to merge into", + "type": "string" }, "body": { - "type": "string", - "description": "PR description" + "description": "PR description", + "type": "string" }, "draft": { - "type": "boolean", - "description": "Create as draft PR" + "description": "Create as draft PR", + "type": "boolean" }, "head": { - "type": "string", - "description": "Branch containing changes" + "description": "Branch containing changes", + "type": "string" }, "maintainer_can_modify": { - "type": "boolean", - "description": "Allow maintainer edits" + "description": "Allow maintainer edits", + "type": "boolean" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "title": { - "type": "string", - "description": "PR title" + "description": "PR title", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "title", + "head", + "base" + ], + "type": "object" }, "name": "create_pull_request" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_repository.snap b/pkg/github/__toolsnaps__/create_repository.snap index 290767c66..2cc4227b2 100644 --- a/pkg/github/__toolsnaps__/create_repository.snap +++ b/pkg/github/__toolsnaps__/create_repository.snap @@ -4,32 +4,32 @@ }, "description": "Create a new GitHub repository in your account or specified organization", "inputSchema": { - "type": "object", - "required": [ - "name" - ], "properties": { "autoInit": { - "type": "boolean", - "description": "Initialize with README" + "description": "Initialize with README", + "type": "boolean" }, "description": { - "type": "string", - "description": "Repository description" + "description": "Repository description", + "type": "string" }, "name": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "organization": { - "type": "string", - "description": "Organization to create the repository in (omit to create in your personal account)" + "description": "Organization to create the repository in (omit to create in your personal account)", + "type": "string" }, "private": { - "type": "boolean", - "description": "Whether repo should be private" + "description": "Whether repo should be private", + "type": "boolean" } - } + }, + "required": [ + "name" + ], + "type": "object" }, "name": "create_repository" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_file.snap b/pkg/github/__toolsnaps__/delete_file.snap index b985154e8..ff110ff78 100644 --- a/pkg/github/__toolsnaps__/delete_file.snap +++ b/pkg/github/__toolsnaps__/delete_file.snap @@ -5,36 +5,36 @@ }, "description": "Delete a file from a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "path", - "message", - "branch" - ], "properties": { "branch": { - "type": "string", - "description": "Branch to delete the file from" + "description": "Branch to delete the file from", + "type": "string" }, "message": { - "type": "string", - "description": "Commit message" + "description": "Commit message", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner (username or organization)" + "description": "Repository owner (username or organization)", + "type": "string" }, "path": { - "type": "string", - "description": "Path to the file to delete" + "description": "Path to the file to delete", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "path", + "message", + "branch" + ], + "type": "object" }, "name": "delete_file" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_project_item.snap b/pkg/github/__toolsnaps__/delete_project_item.snap index 430c83cc8..819fb8474 100644 --- a/pkg/github/__toolsnaps__/delete_project_item.snap +++ b/pkg/github/__toolsnaps__/delete_project_item.snap @@ -5,35 +5,35 @@ }, "description": "Delete a specific Project item for a user or org", "inputSchema": { - "type": "object", - "required": [ - "owner_type", - "owner", - "project_number", - "item_id" - ], "properties": { "item_id": { - "type": "number", - "description": "The internal project item ID to delete from the project (not the issue or pull request ID)." + "description": "The internal project item ID to delete from the project (not the issue or pull request ID).", + "type": "number" }, "owner": { - "type": "string", - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." + "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", + "type": "string" }, "owner_type": { - "type": "string", "description": "Owner type", "enum": [ "user", "org" - ] + ], + "type": "string" }, "project_number": { - "type": "number", - "description": "The project's number." + "description": "The project's number.", + "type": "number" } - } + }, + "required": [ + "owner_type", + "owner", + "project_number", + "item_id" + ], + "type": "object" }, "name": "delete_project_item" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/dismiss_notification.snap b/pkg/github/__toolsnaps__/dismiss_notification.snap index b0125ba53..6c65d9ce0 100644 --- a/pkg/github/__toolsnaps__/dismiss_notification.snap +++ b/pkg/github/__toolsnaps__/dismiss_notification.snap @@ -4,25 +4,25 @@ }, "description": "Dismiss a notification by marking it as read or done", "inputSchema": { - "type": "object", - "required": [ - "threadID", - "state" - ], "properties": { "state": { - "type": "string", "description": "The new state of the notification (read/done)", "enum": [ "read", "done" - ] + ], + "type": "string" }, "threadID": { - "type": "string", - "description": "The ID of the notification thread" + "description": "The ID of the notification thread", + "type": "string" } - } + }, + "required": [ + "threadID", + "state" + ], + "type": "object" }, "name": "dismiss_notification" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/fork_repository.snap b/pkg/github/__toolsnaps__/fork_repository.snap index 18525a4f7..d635734a9 100644 --- a/pkg/github/__toolsnaps__/fork_repository.snap +++ b/pkg/github/__toolsnaps__/fork_repository.snap @@ -3,38 +3,38 @@ "title": "Fork repository" }, "description": "Fork a GitHub repository to your account or specified organization", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], - "properties": { - "organization": { - "type": "string", - "description": "Organization to fork to" - }, - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - } - } - }, - "name": "fork_repository", "icons": [ { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACuElEQVRIibWTTUhUYRiFn/fOdYyoydQxk4LEGzN3RudaLYL+qRaBQYsIItoHCW37ISNbRwUFLWoRZBEt+4EIooKoTdZQ6TWaNIgouzJkuGhG731b6JTojDNBntX3ne+c97zfH8wzZCbREm9bZ4hsQvkeDvl3+/r6xuYqEIvFFgdSvRuDqCrPMu6bVyUDrITTjdI1jR8KBbrj/fs3Q8WLp5p9Qx4BzVOUInIm058+XdAY0ztH6RLhSpAza1RlI2jENzhfqntfjAugEdTYMFEtS0GvonrKslNrZwWIhDYDMh6Wo4ODvaMfB9LPFaMHZGvJ8xHdAlzPDLx+8Smd/pE39SggAptnB2gwDBD6ReJvhSCpMFyq/uSa/NFX5UMJgGCaxywMwiH/bi4wh0SCOy1x5waiCUF2gnSW3AByEfSSZTsPVXFF9CDC4ALx7xU0ocLA87x8tG7ZHRUShsheVMKInMy46culArIj317WRpd7KB2GsAl4bKoccN2330t5ALBsJ7ASTvecoun6hNNt2U5QbM0oRip8E6Wt0gCUFPC12FKoGFnX0BgBDtVGG3/W1qzqz2a/5IrpLGt9pLahvhPhCKrnsiPDT2dqZv1kgGQyGc4FZg+wr8I93F6y0DzY29s7XlHAnw7j7dswgg2oRCYZPTBluzk51VEwXmQG0k8qbGRuWHbqiWWn/qlY0Uv+n5j3gKKvaCaSyeSimrqms4hsB4kurW9c0bSs/pnneflyXrOcACCn5jWEPSr0AAgczvlVTVT+ykojFlvTZNmOWvHU8QJnJVInLNtR2163vJy/7B0EpjYAqBhugVMVF8A3goZy/rJHFGa8P4fpCXosHm9PqwbiwzHAqyLvlvPP+dEKWG23dyh6C1g0RY0Jsv+Dm77/XwIAWlpbVzJh7gLAnHjw8d27z5V65xW/AVGM6Ekx9nZCAAAAAElFTkSuQmCC", "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACuElEQVRIibWTTUhUYRiFn/fOdYyoydQxk4LEGzN3RudaLYL+qRaBQYsIItoHCW37ISNbRwUFLWoRZBEt+4EIooKoTdZQ6TWaNIgouzJkuGhG731b6JTojDNBntX3ne+c97zfH8wzZCbREm9bZ4hsQvkeDvl3+/r6xuYqEIvFFgdSvRuDqCrPMu6bVyUDrITTjdI1jR8KBbrj/fs3Q8WLp5p9Qx4BzVOUInIm058+XdAY0ztH6RLhSpAza1RlI2jENzhfqntfjAugEdTYMFEtS0GvonrKslNrZwWIhDYDMh6Wo4ODvaMfB9LPFaMHZGvJ8xHdAlzPDLx+8Smd/pE39SggAptnB2gwDBD6ReJvhSCpMFyq/uSa/NFX5UMJgGCaxywMwiH/bi4wh0SCOy1x5waiCUF2gnSW3AByEfSSZTsPVXFF9CDC4ALx7xU0ocLA87x8tG7ZHRUShsheVMKInMy46culArIj317WRpd7KB2GsAl4bKoccN2330t5ALBsJ7ASTvecoun6hNNt2U5QbM0oRip8E6Wt0gCUFPC12FKoGFnX0BgBDtVGG3/W1qzqz2a/5IrpLGt9pLahvhPhCKrnsiPDT2dqZv1kgGQyGc4FZg+wr8I93F6y0DzY29s7XlHAnw7j7dswgg2oRCYZPTBluzk51VEwXmQG0k8qbGRuWHbqiWWn/qlY0Uv+n5j3gKKvaCaSyeSimrqms4hsB4kurW9c0bSs/pnneflyXrOcACCn5jWEPSr0AAgczvlVTVT+ykojFlvTZNmOWvHU8QJnJVInLNtR2163vJy/7B0EpjYAqBhugVMVF8A3goZy/rJHFGa8P4fpCXosHm9PqwbiwzHAqyLvlvPP+dEKWG23dyh6C1g0RY0Jsv+Dm77/XwIAWlpbVzJh7gLAnHjw8d27z5V65xW/AVGM6Ekx9nZCAAAAAElFTkSuQmCC", "theme": "light" }, { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABoUlEQVRIibWUPS+EQRSFz0hsxaIgIZHYllDYiiiUEn6Bn0Dho6Nhf4GPjkYn8ZEgGqFRSNBoVTQKdhEROsk+mrt2svvu7Gutk7yZzJlz77nzztyR/hmulADSkkYk5SQdO+c+QwmAZkkTktolXTjnbkLiDJCniHsgFdCnTFNAHliuJE6bYANoAYaBF+AwYHBkmiGgFdi0HINR4lmrotXjVoG3gMEbsOLN2yzHTIFr8PRZG3s9rs/jo5At0fd6fFk1TfY/X4A14MyqmQrsYNo0pxbzCtwBTZUCUsAh8GHCKaDspnl6ZyZ3FnMA9AR2/BOYBzJVhUV9BshHrTVEkZKeJPXHNZA0IOkxttrrhzkgGdAlgXnTLv3GIAHsEh87QGNUrooHaEajkoYlFXYxaeO2je+SLp1z57Grr2J4DvwqWaVDrhv+3SAWrMvXgWcgZ10b3a01GuwDX8CWfV/AXr2Sd9lVXPC4ReM6q8XHOYMOG2897rZkrXZY0+WAK6DHHsRr4xJ/NjCTcXstC/gAxuPEBju5xKRb0phNT5xzD7UUW3d8A4p92DZKdSwEAAAAAElFTkSuQmCC", "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABoUlEQVRIibWUPS+EQRSFz0hsxaIgIZHYllDYiiiUEn6Bn0Dho6Nhf4GPjkYn8ZEgGqFRSNBoVTQKdhEROsk+mrt2svvu7Gutk7yZzJlz77nzztyR/hmulADSkkYk5SQdO+c+QwmAZkkTktolXTjnbkLiDJCniHsgFdCnTFNAHliuJE6bYANoAYaBF+AwYHBkmiGgFdi0HINR4lmrotXjVoG3gMEbsOLN2yzHTIFr8PRZG3s9rs/jo5At0fd6fFk1TfY/X4A14MyqmQrsYNo0pxbzCtwBTZUCUsAh8GHCKaDspnl6ZyZ3FnMA9AR2/BOYBzJVhUV9BshHrTVEkZKeJPXHNZA0IOkxttrrhzkgGdAlgXnTLv3GIAHsEh87QGNUrooHaEajkoYlFXYxaeO2je+SLp1z57Grr2J4DvwqWaVDrhv+3SAWrMvXgWcgZ10b3a01GuwDX8CWfV/AXr2Sd9lVXPC4ReM6q8XHOYMOG2897rZkrXZY0+WAK6DHHsRr4xJ/NjCTcXstC/gAxuPEBju5xKRb0phNT5xzD7UUW3d8A4p92DZKdSwEAAAAAElFTkSuQmCC", "theme": "dark" } - ] + ], + "inputSchema": { + "properties": { + "organization": { + "description": "Organization to fork to", + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "fork_repository" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_code_scanning_alert.snap b/pkg/github/__toolsnaps__/get_code_scanning_alert.snap index 9e46b960a..2a65aefa6 100644 --- a/pkg/github/__toolsnaps__/get_code_scanning_alert.snap +++ b/pkg/github/__toolsnaps__/get_code_scanning_alert.snap @@ -5,26 +5,26 @@ }, "description": "Get details of a specific code scanning alert in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "alertNumber" - ], "properties": { "alertNumber": { - "type": "number", - "description": "The number of the alert." + "description": "The number of the alert.", + "type": "number" }, "owner": { - "type": "string", - "description": "The owner of the repository." + "description": "The owner of the repository.", + "type": "string" }, "repo": { - "type": "string", - "description": "The name of the repository." + "description": "The name of the repository.", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "alertNumber" + ], + "type": "object" }, "name": "get_code_scanning_alert" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_commit.snap b/pkg/github/__toolsnaps__/get_commit.snap index c6b96d5ed..9e2346b59 100644 --- a/pkg/github/__toolsnaps__/get_commit.snap +++ b/pkg/github/__toolsnaps__/get_commit.snap @@ -5,42 +5,42 @@ }, "description": "Get details for a commit from a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "sha" - ], "properties": { "include_diff": { - "type": "boolean", + "default": true, "description": "Whether to include file diffs and stats in the response. Default is true.", - "default": true + "type": "boolean" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "sha": { - "type": "string", - "description": "Commit SHA, branch name, or tag name" + "description": "Commit SHA, branch name, or tag name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "sha" + ], + "type": "object" }, "name": "get_commit" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_dependabot_alert.snap b/pkg/github/__toolsnaps__/get_dependabot_alert.snap index a517809e2..78ff827d2 100644 --- a/pkg/github/__toolsnaps__/get_dependabot_alert.snap +++ b/pkg/github/__toolsnaps__/get_dependabot_alert.snap @@ -5,26 +5,26 @@ }, "description": "Get details of a specific dependabot alert in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "alertNumber" - ], "properties": { "alertNumber": { - "type": "number", - "description": "The number of the alert." + "description": "The number of the alert.", + "type": "number" }, "owner": { - "type": "string", - "description": "The owner of the repository." + "description": "The owner of the repository.", + "type": "string" }, "repo": { - "type": "string", - "description": "The name of the repository." + "description": "The name of the repository.", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "alertNumber" + ], + "type": "object" }, "name": "get_dependabot_alert" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_discussion.snap b/pkg/github/__toolsnaps__/get_discussion.snap index feef0f057..b931afe79 100644 --- a/pkg/github/__toolsnaps__/get_discussion.snap +++ b/pkg/github/__toolsnaps__/get_discussion.snap @@ -5,26 +5,26 @@ }, "description": "Get a specific discussion by ID", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "discussionNumber" - ], "properties": { "discussionNumber": { - "type": "number", - "description": "Discussion Number" + "description": "Discussion Number", + "type": "number" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "discussionNumber" + ], + "type": "object" }, "name": "get_discussion" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_discussion_comments.snap b/pkg/github/__toolsnaps__/get_discussion_comments.snap index 3af5edc8c..f9e609565 100644 --- a/pkg/github/__toolsnaps__/get_discussion_comments.snap +++ b/pkg/github/__toolsnaps__/get_discussion_comments.snap @@ -5,36 +5,36 @@ }, "description": "Get comments from a discussion", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "discussionNumber" - ], "properties": { "after": { - "type": "string", - "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs." + "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + "type": "string" }, "discussionNumber": { - "type": "number", - "description": "Discussion Number" + "description": "Discussion Number", + "type": "number" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "discussionNumber" + ], + "type": "object" }, "name": "get_discussion_comments" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_file_contents.snap b/pkg/github/__toolsnaps__/get_file_contents.snap index 638452fe7..94b7aeeda 100644 --- a/pkg/github/__toolsnaps__/get_file_contents.snap +++ b/pkg/github/__toolsnaps__/get_file_contents.snap @@ -5,34 +5,34 @@ }, "description": "Get the contents of a file or directory from a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner (username or organization)" + "description": "Repository owner (username or organization)", + "type": "string" }, "path": { - "type": "string", + "default": "/", "description": "Path to file/directory", - "default": "/" + "type": "string" }, "ref": { - "type": "string", - "description": "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`" + "description": "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "sha": { - "type": "string", - "description": "Accepts optional commit SHA. If specified, it will be used instead of ref" + "description": "Accepts optional commit SHA. If specified, it will be used instead of ref", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "get_file_contents" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_gist.snap b/pkg/github/__toolsnaps__/get_gist.snap index 4d2661822..ef316937f 100644 --- a/pkg/github/__toolsnaps__/get_gist.snap +++ b/pkg/github/__toolsnaps__/get_gist.snap @@ -5,16 +5,16 @@ }, "description": "Get gist content of a particular gist, by gist ID", "inputSchema": { - "type": "object", - "required": [ - "gist_id" - ], "properties": { "gist_id": { - "type": "string", - "description": "The ID of the gist" + "description": "The ID of the gist", + "type": "string" } - } + }, + "required": [ + "gist_id" + ], + "type": "object" }, "name": "get_gist" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_global_security_advisory.snap b/pkg/github/__toolsnaps__/get_global_security_advisory.snap index 18c30425a..97b81d17d 100644 --- a/pkg/github/__toolsnaps__/get_global_security_advisory.snap +++ b/pkg/github/__toolsnaps__/get_global_security_advisory.snap @@ -5,16 +5,16 @@ }, "description": "Get a global security advisory", "inputSchema": { - "type": "object", - "required": [ - "ghsaId" - ], "properties": { "ghsaId": { - "type": "string", - "description": "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)." + "description": "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", + "type": "string" } - } + }, + "required": [ + "ghsaId" + ], + "type": "object" }, "name": "get_global_security_advisory" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_label.snap b/pkg/github/__toolsnaps__/get_label.snap index 8541044d0..854f048c2 100644 --- a/pkg/github/__toolsnaps__/get_label.snap +++ b/pkg/github/__toolsnaps__/get_label.snap @@ -5,26 +5,26 @@ }, "description": "Get a specific label from a repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "name" - ], "properties": { "name": { - "type": "string", - "description": "Label name." + "description": "Label name.", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner (username or organization name)" + "description": "Repository owner (username or organization name)", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "name" + ], + "type": "object" }, "name": "get_label" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_latest_release.snap b/pkg/github/__toolsnaps__/get_latest_release.snap index 23b551a0f..760d8f812 100644 --- a/pkg/github/__toolsnaps__/get_latest_release.snap +++ b/pkg/github/__toolsnaps__/get_latest_release.snap @@ -5,21 +5,21 @@ }, "description": "Get the latest release in a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "get_latest_release" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_me.snap b/pkg/github/__toolsnaps__/get_me.snap index e6d02929f..4d7d2573b 100644 --- a/pkg/github/__toolsnaps__/get_me.snap +++ b/pkg/github/__toolsnaps__/get_me.snap @@ -5,8 +5,8 @@ }, "description": "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls.", "inputSchema": { - "type": "object", - "properties": {} + "properties": {}, + "type": "object" }, "name": "get_me" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_notification_details.snap b/pkg/github/__toolsnaps__/get_notification_details.snap index de197f2b1..48842229f 100644 --- a/pkg/github/__toolsnaps__/get_notification_details.snap +++ b/pkg/github/__toolsnaps__/get_notification_details.snap @@ -5,16 +5,16 @@ }, "description": "Get detailed information for a specific GitHub notification, always call this tool when the user asks for details about a specific notification, if you don't know the ID list notifications first.", "inputSchema": { - "type": "object", - "required": [ - "notificationID" - ], "properties": { "notificationID": { - "type": "string", - "description": "The ID of the notification" + "description": "The ID of the notification", + "type": "string" } - } + }, + "required": [ + "notificationID" + ], + "type": "object" }, "name": "get_notification_details" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_project.snap b/pkg/github/__toolsnaps__/get_project.snap index 8194b7358..6ff320fe8 100644 --- a/pkg/github/__toolsnaps__/get_project.snap +++ b/pkg/github/__toolsnaps__/get_project.snap @@ -5,30 +5,30 @@ }, "description": "Get Project for a user or org", "inputSchema": { - "type": "object", - "required": [ - "project_number", - "owner_type", - "owner" - ], "properties": { "owner": { - "type": "string", - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." + "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", + "type": "string" }, "owner_type": { - "type": "string", "description": "Owner type", "enum": [ "user", "org" - ] + ], + "type": "string" }, "project_number": { - "type": "number", - "description": "The project's number" + "description": "The project's number", + "type": "number" } - } + }, + "required": [ + "project_number", + "owner_type", + "owner" + ], + "type": "object" }, "name": "get_project" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_project_field.snap b/pkg/github/__toolsnaps__/get_project_field.snap index 0df557a03..9d884a20f 100644 --- a/pkg/github/__toolsnaps__/get_project_field.snap +++ b/pkg/github/__toolsnaps__/get_project_field.snap @@ -5,35 +5,35 @@ }, "description": "Get Project field for a user or org", "inputSchema": { - "type": "object", - "required": [ - "owner_type", - "owner", - "project_number", - "field_id" - ], "properties": { "field_id": { - "type": "number", - "description": "The field's id." + "description": "The field's id.", + "type": "number" }, "owner": { - "type": "string", - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." + "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", + "type": "string" }, "owner_type": { - "type": "string", "description": "Owner type", "enum": [ "user", "org" - ] + ], + "type": "string" }, "project_number": { - "type": "number", - "description": "The project's number." + "description": "The project's number.", + "type": "number" } - } + }, + "required": [ + "owner_type", + "owner", + "project_number", + "field_id" + ], + "type": "object" }, "name": "get_project_field" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_project_item.snap b/pkg/github/__toolsnaps__/get_project_item.snap index d77c49c1e..202bcc53e 100644 --- a/pkg/github/__toolsnaps__/get_project_item.snap +++ b/pkg/github/__toolsnaps__/get_project_item.snap @@ -5,42 +5,42 @@ }, "description": "Get a specific Project item for a user or org", "inputSchema": { - "type": "object", - "required": [ - "owner_type", - "owner", - "project_number", - "item_id" - ], "properties": { "fields": { - "type": "array", "description": "Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included.", "items": { "type": "string" - } + }, + "type": "array" }, "item_id": { - "type": "number", - "description": "The item's ID." + "description": "The item's ID.", + "type": "number" }, "owner": { - "type": "string", - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." + "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", + "type": "string" }, "owner_type": { - "type": "string", "description": "Owner type", "enum": [ "user", "org" - ] + ], + "type": "string" }, "project_number": { - "type": "number", - "description": "The project's number." + "description": "The project's number.", + "type": "number" } - } + }, + "required": [ + "owner_type", + "owner", + "project_number", + "item_id" + ], + "type": "object" }, "name": "get_project_item" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_release_by_tag.snap b/pkg/github/__toolsnaps__/get_release_by_tag.snap index 77f19488c..6e6d30e98 100644 --- a/pkg/github/__toolsnaps__/get_release_by_tag.snap +++ b/pkg/github/__toolsnaps__/get_release_by_tag.snap @@ -5,26 +5,26 @@ }, "description": "Get a specific release by its tag name in a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "tag" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "tag": { - "type": "string", - "description": "Tag name (e.g., 'v1.0.0')" + "description": "Tag name (e.g., 'v1.0.0')", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "tag" + ], + "type": "object" }, "name": "get_release_by_tag" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_repository_tree.snap b/pkg/github/__toolsnaps__/get_repository_tree.snap index 882462883..c810d1e20 100644 --- a/pkg/github/__toolsnaps__/get_repository_tree.snap +++ b/pkg/github/__toolsnaps__/get_repository_tree.snap @@ -5,34 +5,34 @@ }, "description": "Get the tree structure (files and directories) of a GitHub repository at a specific ref or SHA", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner (username or organization)" + "description": "Repository owner (username or organization)", + "type": "string" }, "path_filter": { - "type": "string", - "description": "Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory)" + "description": "Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory)", + "type": "string" }, "recursive": { - "type": "boolean", + "default": false, "description": "Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false", - "default": false + "type": "boolean" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "tree_sha": { - "type": "string", - "description": "The SHA1 value or ref (branch or tag) name of the tree. Defaults to the repository's default branch" + "description": "The SHA1 value or ref (branch or tag) name of the tree. Defaults to the repository's default branch", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "get_repository_tree" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap b/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap index 4d55011da..2789cfbab 100644 --- a/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap +++ b/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap @@ -5,26 +5,26 @@ }, "description": "Get details of a specific secret scanning alert in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "alertNumber" - ], "properties": { "alertNumber": { - "type": "number", - "description": "The number of the alert." + "description": "The number of the alert.", + "type": "number" }, "owner": { - "type": "string", - "description": "The owner of the repository." + "description": "The owner of the repository.", + "type": "string" }, "repo": { - "type": "string", - "description": "The name of the repository." + "description": "The name of the repository.", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "alertNumber" + ], + "type": "object" }, "name": "get_secret_scanning_alert" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_tag.snap b/pkg/github/__toolsnaps__/get_tag.snap index e33f5c2e4..126e8a999 100644 --- a/pkg/github/__toolsnaps__/get_tag.snap +++ b/pkg/github/__toolsnaps__/get_tag.snap @@ -5,26 +5,26 @@ }, "description": "Get details about a specific git tag in a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "tag" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "tag": { - "type": "string", - "description": "Tag name" + "description": "Tag name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "tag" + ], + "type": "object" }, "name": "get_tag" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_team_members.snap b/pkg/github/__toolsnaps__/get_team_members.snap index 5b7f090fe..4cde7237c 100644 --- a/pkg/github/__toolsnaps__/get_team_members.snap +++ b/pkg/github/__toolsnaps__/get_team_members.snap @@ -5,21 +5,21 @@ }, "description": "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials", "inputSchema": { - "type": "object", - "required": [ - "org", - "team_slug" - ], "properties": { "org": { - "type": "string", - "description": "Organization login (owner) that contains the team." + "description": "Organization login (owner) that contains the team.", + "type": "string" }, "team_slug": { - "type": "string", - "description": "Team slug" + "description": "Team slug", + "type": "string" } - } + }, + "required": [ + "org", + "team_slug" + ], + "type": "object" }, "name": "get_team_members" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_teams.snap b/pkg/github/__toolsnaps__/get_teams.snap index 595dd262d..946364bad 100644 --- a/pkg/github/__toolsnaps__/get_teams.snap +++ b/pkg/github/__toolsnaps__/get_teams.snap @@ -5,13 +5,13 @@ }, "description": "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials", "inputSchema": { - "type": "object", "properties": { "user": { - "type": "string", - "description": "Username to get teams for. If not provided, uses the authenticated user." + "description": "Username to get teams for. If not provided, uses the authenticated user.", + "type": "string" } - } + }, + "type": "object" }, "name": "get_teams" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_read.snap b/pkg/github/__toolsnaps__/issue_read.snap index c6a9e7306..21aa361f5 100644 --- a/pkg/github/__toolsnaps__/issue_read.snap +++ b/pkg/github/__toolsnaps__/issue_read.snap @@ -5,48 +5,48 @@ }, "description": "Get information about a specific issue in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner", - "repo", - "issue_number" - ], "properties": { "issue_number": { - "type": "number", - "description": "The number of the issue" + "description": "The number of the issue", + "type": "number" }, "method": { - "type": "string", "description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get details of a specific issue.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues of the issue.\n4. get_labels - Get labels assigned to the issue.\n", "enum": [ "get", "get_comments", "get_sub_issues", "get_labels" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "The owner of the repository" + "description": "The owner of the repository", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "The name of the repository" + "description": "The name of the repository", + "type": "string" } - } + }, + "required": [ + "method", + "owner", + "repo", + "issue_number" + ], + "type": "object" }, "name": "issue_read" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index 8c6634a02..4512eb614 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -4,85 +4,85 @@ }, "description": "Create a new or update an existing issue in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner", - "repo" - ], "properties": { "assignees": { - "type": "array", "description": "Usernames to assign to this issue", "items": { "type": "string" - } + }, + "type": "array" }, "body": { - "type": "string", - "description": "Issue body content" + "description": "Issue body content", + "type": "string" }, "duplicate_of": { - "type": "number", - "description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'." + "description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", + "type": "number" }, "issue_number": { - "type": "number", - "description": "Issue number to update" + "description": "Issue number to update", + "type": "number" }, "labels": { - "type": "array", "description": "Labels to apply to this issue", "items": { "type": "string" - } + }, + "type": "array" }, "method": { - "type": "string", "description": "Write operation to perform on a single issue.\nOptions are:\n- 'create' - creates a new issue.\n- 'update' - updates an existing issue.\n", "enum": [ "create", "update" - ] + ], + "type": "string" }, "milestone": { - "type": "number", - "description": "Milestone number" + "description": "Milestone number", + "type": "number" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "state": { - "type": "string", "description": "New state", "enum": [ "open", "closed" - ] + ], + "type": "string" }, "state_reason": { - "type": "string", "description": "Reason for the state change. Ignored unless state is changed.", "enum": [ "completed", "not_planned", "duplicate" - ] + ], + "type": "string" }, "title": { - "type": "string", - "description": "Issue title" + "description": "Issue title", + "type": "string" }, "type": { - "type": "string", - "description": "Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter." + "description": "Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter.", + "type": "string" } - } + }, + "required": [ + "method", + "owner", + "repo" + ], + "type": "object" }, "name": "issue_write" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/label_write.snap b/pkg/github/__toolsnaps__/label_write.snap index 879817442..f0aca8cc9 100644 --- a/pkg/github/__toolsnaps__/label_write.snap +++ b/pkg/github/__toolsnaps__/label_write.snap @@ -4,48 +4,48 @@ }, "description": "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool.", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner", - "repo", - "name" - ], "properties": { "color": { - "type": "string", - "description": "Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'." + "description": "Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'.", + "type": "string" }, "description": { - "type": "string", - "description": "Label description text. Optional for 'create' and 'update'." + "description": "Label description text. Optional for 'create' and 'update'.", + "type": "string" }, "method": { - "type": "string", "description": "Operation to perform: 'create', 'update', or 'delete'", "enum": [ "create", "update", "delete" - ] + ], + "type": "string" }, "name": { - "type": "string", - "description": "Label name - required for all operations" + "description": "Label name - required for all operations", + "type": "string" }, "new_name": { - "type": "string", - "description": "New name for the label (used only with 'update' method to rename)" + "description": "New name for the label (used only with 'update' method to rename)", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner (username or organization name)" + "description": "Repository owner (username or organization name)", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "method", + "owner", + "repo", + "name" + ], + "type": "object" }, "name": "label_write" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_branches.snap b/pkg/github/__toolsnaps__/list_branches.snap index b589c9b7e..883a6fffc 100644 --- a/pkg/github/__toolsnaps__/list_branches.snap +++ b/pkg/github/__toolsnaps__/list_branches.snap @@ -5,32 +5,32 @@ }, "description": "List branches in a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_branches" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap index 6f2a4e342..5b7d79ef4 100644 --- a/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap +++ b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap @@ -5,26 +5,20 @@ }, "description": "List code scanning alerts in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "The owner of the repository." + "description": "The owner of the repository.", + "type": "string" }, "ref": { - "type": "string", - "description": "The Git reference for the results you want to list." + "description": "The Git reference for the results you want to list.", + "type": "string" }, "repo": { - "type": "string", - "description": "The name of the repository." + "description": "The name of the repository.", + "type": "string" }, "severity": { - "type": "string", "description": "Filter code scanning alerts by severity", "enum": [ "critical", @@ -34,24 +28,30 @@ "warning", "note", "error" - ] + ], + "type": "string" }, "state": { - "type": "string", - "description": "Filter code scanning alerts by state. Defaults to open", "default": "open", + "description": "Filter code scanning alerts by state. Defaults to open", "enum": [ "open", "closed", "dismissed", "fixed" - ] + ], + "type": "string" }, "tool_name": { - "type": "string", - "description": "The name of the tool used for code scanning." + "description": "The name of the tool used for code scanning.", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_code_scanning_alerts" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_commits.snap b/pkg/github/__toolsnaps__/list_commits.snap index bd67602ed..38b63736f 100644 --- a/pkg/github/__toolsnaps__/list_commits.snap +++ b/pkg/github/__toolsnaps__/list_commits.snap @@ -5,40 +5,40 @@ }, "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).", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "author": { - "type": "string", - "description": "Author username or email address to filter commits by" + "description": "Author username or email address to filter commits by", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "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." + "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.", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_commits" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap index d96d3972c..83f725987 100644 --- a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap +++ b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap @@ -5,42 +5,42 @@ }, "description": "List dependabot alerts in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "The owner of the repository." + "description": "The owner of the repository.", + "type": "string" }, "repo": { - "type": "string", - "description": "The name of the repository." + "description": "The name of the repository.", + "type": "string" }, "severity": { - "type": "string", "description": "Filter dependabot alerts by severity", "enum": [ "low", "medium", "high", "critical" - ] + ], + "type": "string" }, "state": { - "type": "string", - "description": "Filter dependabot alerts by state. Defaults to open", "default": "open", + "description": "Filter dependabot alerts by state. Defaults to open", "enum": [ "open", "fixed", "dismissed", "auto_dismissed" - ] + ], + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_dependabot_alerts" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_discussion_categories.snap b/pkg/github/__toolsnaps__/list_discussion_categories.snap index 888ebbdca..c46b75f84 100644 --- a/pkg/github/__toolsnaps__/list_discussion_categories.snap +++ b/pkg/github/__toolsnaps__/list_discussion_categories.snap @@ -5,20 +5,20 @@ }, "description": "List discussion categories with their id and name, for a repository or organisation.", "inputSchema": { - "type": "object", - "required": [ - "owner" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name. If not provided, discussion categories will be queried at the organisation level." + "description": "Repository name. If not provided, discussion categories will be queried at the organisation level.", + "type": "string" } - } + }, + "required": [ + "owner" + ], + "type": "object" }, "name": "list_discussion_categories" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_discussions.snap b/pkg/github/__toolsnaps__/list_discussions.snap index 95a8bebf5..42be76933 100644 --- a/pkg/github/__toolsnaps__/list_discussions.snap +++ b/pkg/github/__toolsnaps__/list_discussions.snap @@ -5,50 +5,50 @@ }, "description": "List discussions for a repository or organisation.", "inputSchema": { - "type": "object", - "required": [ - "owner" - ], "properties": { "after": { - "type": "string", - "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs." + "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + "type": "string" }, "category": { - "type": "string", - "description": "Optional filter by discussion category ID. If provided, only discussions with this category are listed." + "description": "Optional filter by discussion category ID. If provided, only discussions with this category are listed.", + "type": "string" }, "direction": { - "type": "string", "description": "Order direction.", "enum": [ "ASC", "DESC" - ] + ], + "type": "string" }, "orderBy": { - "type": "string", "description": "Order discussions by field. If provided, the 'direction' also needs to be provided.", "enum": [ "CREATED_AT", "UPDATED_AT" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name. If not provided, discussions will be queried at the organisation level." + "description": "Repository name. If not provided, discussions will be queried at the organisation level.", + "type": "string" } - } + }, + "required": [ + "owner" + ], + "type": "object" }, "name": "list_discussions" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_gists.snap b/pkg/github/__toolsnaps__/list_gists.snap index 834b45205..397417303 100644 --- a/pkg/github/__toolsnaps__/list_gists.snap +++ b/pkg/github/__toolsnaps__/list_gists.snap @@ -5,28 +5,28 @@ }, "description": "List gists for a user", "inputSchema": { - "type": "object", "properties": { "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "since": { - "type": "string", - "description": "Only gists updated after this time (ISO 8601 timestamp)" + "description": "Only gists updated after this time (ISO 8601 timestamp)", + "type": "string" }, "username": { - "type": "string", - "description": "GitHub username (omit for authenticated user's gists)" + "description": "GitHub username (omit for authenticated user's gists)", + "type": "string" } - } + }, + "type": "object" }, "name": "list_gists" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_global_security_advisories.snap b/pkg/github/__toolsnaps__/list_global_security_advisories.snap index fd9fa78c5..f714f4782 100644 --- a/pkg/github/__toolsnaps__/list_global_security_advisories.snap +++ b/pkg/github/__toolsnaps__/list_global_security_advisories.snap @@ -5,25 +5,23 @@ }, "description": "List global security advisories from GitHub.", "inputSchema": { - "type": "object", "properties": { "affects": { - "type": "string", - "description": "Filter advisories by affected package or version (e.g. \"package1,package2@1.0.0\")." + "description": "Filter advisories by affected package or version (e.g. \"package1,package2@1.0.0\").", + "type": "string" }, "cveId": { - "type": "string", - "description": "Filter by CVE ID." + "description": "Filter by CVE ID.", + "type": "string" }, "cwes": { - "type": "array", "description": "Filter by Common Weakness Enumeration IDs (e.g. [\"79\", \"284\", \"22\"]).", "items": { "type": "string" - } + }, + "type": "array" }, "ecosystem": { - "type": "string", "description": "Filter by package ecosystem.", "enum": [ "actions", @@ -38,26 +36,26 @@ "pub", "rubygems", "rust" - ] + ], + "type": "string" }, "ghsaId": { - "type": "string", - "description": "Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)." + "description": "Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", + "type": "string" }, "isWithdrawn": { - "type": "boolean", - "description": "Whether to only return withdrawn advisories." + "description": "Whether to only return withdrawn advisories.", + "type": "boolean" }, "modified": { - "type": "string", - "description": "Filter by publish or update date or date range (ISO 8601 date or range)." + "description": "Filter by publish or update date or date range (ISO 8601 date or range).", + "type": "string" }, "published": { - "type": "string", - "description": "Filter by publish date or date range (ISO 8601 date or range)." + "description": "Filter by publish date or date range (ISO 8601 date or range).", + "type": "string" }, "severity": { - "type": "string", "description": "Filter by severity.", "enum": [ "unknown", @@ -65,23 +63,25 @@ "medium", "high", "critical" - ] + ], + "type": "string" }, "type": { - "type": "string", - "description": "Advisory type.", "default": "reviewed", + "description": "Advisory type.", "enum": [ "reviewed", "malware", "unreviewed" - ] + ], + "type": "string" }, "updated": { - "type": "string", - "description": "Filter by update date or date range (ISO 8601 date or range)." + "description": "Filter by update date or date range (ISO 8601 date or range).", + "type": "string" } - } + }, + "type": "object" }, "name": "list_global_security_advisories" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issue_types.snap b/pkg/github/__toolsnaps__/list_issue_types.snap index b17dcc54f..f1f1377a8 100644 --- a/pkg/github/__toolsnaps__/list_issue_types.snap +++ b/pkg/github/__toolsnaps__/list_issue_types.snap @@ -5,16 +5,16 @@ }, "description": "List supported issue types for repository owner (organization).", "inputSchema": { - "type": "object", - "required": [ - "owner" - ], "properties": { "owner": { - "type": "string", - "description": "The organization owner of the repository" + "description": "The organization owner of the repository", + "type": "string" } - } + }, + "required": [ + "owner" + ], + "type": "object" }, "name": "list_issue_types" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap index 9d6b55586..a4be59bb0 100644 --- a/pkg/github/__toolsnaps__/list_issues.snap +++ b/pkg/github/__toolsnaps__/list_issues.snap @@ -5,67 +5,67 @@ }, "description": "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "after": { - "type": "string", - "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs." + "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + "type": "string" }, "direction": { - "type": "string", "description": "Order direction. If provided, the 'orderBy' also needs to be provided.", "enum": [ "ASC", "DESC" - ] + ], + "type": "string" }, "labels": { - "type": "array", "description": "Filter by labels", "items": { "type": "string" - } + }, + "type": "array" }, "orderBy": { - "type": "string", "description": "Order issues by field. If provided, the 'direction' also needs to be provided.", "enum": [ "CREATED_AT", "UPDATED_AT", "COMMENTS" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "since": { - "type": "string", - "description": "Filter by date (ISO 8601 timestamp)" + "description": "Filter by date (ISO 8601 timestamp)", + "type": "string" }, "state": { - "type": "string", "description": "Filter by state, by default both open and closed issues are returned when not provided", "enum": [ "OPEN", "CLOSED" - ] + ], + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_issues" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_label.snap b/pkg/github/__toolsnaps__/list_label.snap index 0b4f3b20c..debc2d44e 100644 --- a/pkg/github/__toolsnaps__/list_label.snap +++ b/pkg/github/__toolsnaps__/list_label.snap @@ -5,21 +5,21 @@ }, "description": "List labels from a repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner (username or organization name) - required for all operations" + "description": "Repository owner (username or organization name) - required for all operations", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name - required for all operations" + "description": "Repository name - required for all operations", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_label" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_notifications.snap b/pkg/github/__toolsnaps__/list_notifications.snap index ae43e0f25..bf25c4fe0 100644 --- a/pkg/github/__toolsnaps__/list_notifications.snap +++ b/pkg/github/__toolsnaps__/list_notifications.snap @@ -5,45 +5,45 @@ }, "description": "Lists all GitHub notifications for the authenticated user, including unread notifications, mentions, review requests, assignments, and updates on issues or pull requests. Use this tool whenever the user asks what to work on next, requests a summary of their GitHub activity, wants to see pending reviews, or needs to check for new updates or tasks. This tool is the primary way to discover actionable items, reminders, and outstanding work on GitHub. Always call this tool when asked what to work on next, what is pending, or what needs attention in GitHub.", "inputSchema": { - "type": "object", "properties": { "before": { - "type": "string", - "description": "Only show notifications updated before the given time (ISO 8601 format)" + "description": "Only show notifications updated before the given time (ISO 8601 format)", + "type": "string" }, "filter": { - "type": "string", "description": "Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created.", "enum": [ "default", "include_read_notifications", "only_participating" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Optional repository owner. If provided with repo, only notifications for this repository are listed." + "description": "Optional repository owner. If provided with repo, only notifications for this repository are listed.", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Optional repository name. If provided with owner, only notifications for this repository are listed." + "description": "Optional repository name. If provided with owner, only notifications for this repository are listed.", + "type": "string" }, "since": { - "type": "string", - "description": "Only show notifications updated after the given time (ISO 8601 format)" + "description": "Only show notifications updated after the given time (ISO 8601 format)", + "type": "string" } - } + }, + "type": "object" }, "name": "list_notifications" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap b/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap index 5f8823659..563da98c3 100644 --- a/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap +++ b/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap @@ -5,43 +5,43 @@ }, "description": "List repository security advisories for a GitHub organization.", "inputSchema": { - "type": "object", - "required": [ - "org" - ], "properties": { "direction": { - "type": "string", "description": "Sort direction.", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "org": { - "type": "string", - "description": "The organization login." + "description": "The organization login.", + "type": "string" }, "sort": { - "type": "string", "description": "Sort field.", "enum": [ "created", "updated", "published" - ] + ], + "type": "string" }, "state": { - "type": "string", "description": "Filter by advisory state.", "enum": [ "triage", "draft", "published", "closed" - ] + ], + "type": "string" } - } + }, + "required": [ + "org" + ], + "type": "object" }, "name": "list_org_repository_security_advisories" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_project_fields.snap b/pkg/github/__toolsnaps__/list_project_fields.snap index 6bef18507..5456388b2 100644 --- a/pkg/github/__toolsnaps__/list_project_fields.snap +++ b/pkg/github/__toolsnaps__/list_project_fields.snap @@ -5,42 +5,42 @@ }, "description": "List Project fields for a user or org", "inputSchema": { - "type": "object", - "required": [ - "owner_type", - "owner", - "project_number" - ], "properties": { "after": { - "type": "string", - "description": "Forward pagination cursor from previous pageInfo.nextCursor." + "description": "Forward pagination cursor from previous pageInfo.nextCursor.", + "type": "string" }, "before": { - "type": "string", - "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare)." + "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).", + "type": "string" }, "owner": { - "type": "string", - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." + "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", + "type": "string" }, "owner_type": { - "type": "string", "description": "Owner type", "enum": [ "user", "org" - ] + ], + "type": "string" }, "per_page": { - "type": "number", - "description": "Results per page (max 50)" + "description": "Results per page (max 50)", + "type": "number" }, "project_number": { - "type": "number", - "description": "The project's number." + "description": "The project's number.", + "type": "number" } - } + }, + "required": [ + "owner_type", + "owner", + "project_number" + ], + "type": "object" }, "name": "list_project_fields" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_project_items.snap b/pkg/github/__toolsnaps__/list_project_items.snap index bceb5d9eb..5089f4306 100644 --- a/pkg/github/__toolsnaps__/list_project_items.snap +++ b/pkg/github/__toolsnaps__/list_project_items.snap @@ -5,53 +5,53 @@ }, "description": "Search project items with advanced filtering", "inputSchema": { - "type": "object", - "required": [ - "owner_type", - "owner", - "project_number" - ], "properties": { "after": { - "type": "string", - "description": "Forward pagination cursor from previous pageInfo.nextCursor." + "description": "Forward pagination cursor from previous pageInfo.nextCursor.", + "type": "string" }, "before": { - "type": "string", - "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare)." + "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).", + "type": "string" }, "fields": { - "type": "array", "description": "Field IDs to include (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned.", "items": { "type": "string" - } + }, + "type": "array" }, "owner": { - "type": "string", - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." + "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", + "type": "string" }, "owner_type": { - "type": "string", "description": "Owner type", "enum": [ "user", "org" - ] + ], + "type": "string" }, "per_page": { - "type": "number", - "description": "Results per page (max 50)" + "description": "Results per page (max 50)", + "type": "number" }, "project_number": { - "type": "number", - "description": "The project's number." + "description": "The project's number.", + "type": "number" }, "query": { - "type": "string", - "description": "Query string for advanced filtering of project items using GitHub's project filtering syntax." + "description": "Query string for advanced filtering of project items using GitHub's project filtering syntax.", + "type": "string" } - } + }, + "required": [ + "owner_type", + "owner", + "project_number" + ], + "type": "object" }, "name": "list_project_items" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_projects.snap b/pkg/github/__toolsnaps__/list_projects.snap index f48e26217..be5a6713e 100644 --- a/pkg/github/__toolsnaps__/list_projects.snap +++ b/pkg/github/__toolsnaps__/list_projects.snap @@ -5,41 +5,41 @@ }, "description": "List Projects for a user or organization", "inputSchema": { - "type": "object", - "required": [ - "owner_type", - "owner" - ], "properties": { "after": { - "type": "string", - "description": "Forward pagination cursor from previous pageInfo.nextCursor." + "description": "Forward pagination cursor from previous pageInfo.nextCursor.", + "type": "string" }, "before": { - "type": "string", - "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare)." + "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).", + "type": "string" }, "owner": { - "type": "string", - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." + "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", + "type": "string" }, "owner_type": { - "type": "string", "description": "Owner type", "enum": [ "user", "org" - ] + ], + "type": "string" }, "per_page": { - "type": "number", - "description": "Results per page (max 50)" + "description": "Results per page (max 50)", + "type": "number" }, "query": { - "type": "string", - "description": "Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: \"roadmap is:open\", \"is:open feature planning\"." + "description": "Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: \"roadmap is:open\", \"is:open feature planning\".", + "type": "string" } - } + }, + "required": [ + "owner_type", + "owner" + ], + "type": "object" }, "name": "list_projects" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_pull_requests.snap b/pkg/github/__toolsnaps__/list_pull_requests.snap index ae90c3fe0..25f1268c6 100644 --- a/pkg/github/__toolsnaps__/list_pull_requests.snap +++ b/pkg/github/__toolsnaps__/list_pull_requests.snap @@ -5,67 +5,67 @@ }, "description": "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "base": { - "type": "string", - "description": "Filter by base branch" + "description": "Filter by base branch", + "type": "string" }, "direction": { - "type": "string", "description": "Sort direction", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "head": { - "type": "string", - "description": "Filter by head user/org and branch" + "description": "Filter by head user/org and branch", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "sort": { - "type": "string", "description": "Sort by", "enum": [ "created", "updated", "popularity", "long-running" - ] + ], + "type": "string" }, "state": { - "type": "string", "description": "Filter by state", "enum": [ "open", "closed", "all" - ] + ], + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_pull_requests" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_releases.snap b/pkg/github/__toolsnaps__/list_releases.snap index 98d4ce66f..57502c3c8 100644 --- a/pkg/github/__toolsnaps__/list_releases.snap +++ b/pkg/github/__toolsnaps__/list_releases.snap @@ -5,32 +5,32 @@ }, "description": "List releases in a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_releases" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_repository_security_advisories.snap b/pkg/github/__toolsnaps__/list_repository_security_advisories.snap index 465fd881e..c86508f92 100644 --- a/pkg/github/__toolsnaps__/list_repository_security_advisories.snap +++ b/pkg/github/__toolsnaps__/list_repository_security_advisories.snap @@ -5,48 +5,48 @@ }, "description": "List repository security advisories for a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "direction": { - "type": "string", "description": "Sort direction.", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "The owner of the repository." + "description": "The owner of the repository.", + "type": "string" }, "repo": { - "type": "string", - "description": "The name of the repository." + "description": "The name of the repository.", + "type": "string" }, "sort": { - "type": "string", "description": "Sort field.", "enum": [ "created", "updated", "published" - ] + ], + "type": "string" }, "state": { - "type": "string", "description": "Filter by advisory state.", "enum": [ "triage", "draft", "published", "closed" - ] + ], + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_repository_security_advisories" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap b/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap index e7896c55f..f2f7cb125 100644 --- a/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap +++ b/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap @@ -5,22 +5,16 @@ }, "description": "List secret scanning alerts in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "The owner of the repository." + "description": "The owner of the repository.", + "type": "string" }, "repo": { - "type": "string", - "description": "The name of the repository." + "description": "The name of the repository.", + "type": "string" }, "resolution": { - "type": "string", "description": "Filter by resolution", "enum": [ "false_positive", @@ -29,21 +23,27 @@ "pattern_edited", "pattern_deleted", "used_in_tests" - ] + ], + "type": "string" }, "secret_type": { - "type": "string", - "description": "A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter." + "description": "A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter.", + "type": "string" }, "state": { - "type": "string", "description": "Filter by state", "enum": [ "open", "resolved" - ] + ], + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_secret_scanning_alerts" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_starred_repositories.snap b/pkg/github/__toolsnaps__/list_starred_repositories.snap index a383b39d1..e631719fd 100644 --- a/pkg/github/__toolsnaps__/list_starred_repositories.snap +++ b/pkg/github/__toolsnaps__/list_starred_repositories.snap @@ -5,40 +5,40 @@ }, "description": "List starred repositories", "inputSchema": { - "type": "object", "properties": { "direction": { - "type": "string", "description": "The direction to sort the results by.", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "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": [ "created", "updated" - ] + ], + "type": "string" }, "username": { - "type": "string", - "description": "Username to list starred repositories for. Defaults to the authenticated user." + "description": "Username to list starred repositories for. Defaults to the authenticated user.", + "type": "string" } - } + }, + "type": "object" }, "name": "list_starred_repositories" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_tags.snap b/pkg/github/__toolsnaps__/list_tags.snap index 5b667d19c..1e66d2c1f 100644 --- a/pkg/github/__toolsnaps__/list_tags.snap +++ b/pkg/github/__toolsnaps__/list_tags.snap @@ -5,32 +5,32 @@ }, "description": "List git tags in a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_tags" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/manage_notification_subscription.snap b/pkg/github/__toolsnaps__/manage_notification_subscription.snap index 4f0d466a0..e04acd11e 100644 --- a/pkg/github/__toolsnaps__/manage_notification_subscription.snap +++ b/pkg/github/__toolsnaps__/manage_notification_subscription.snap @@ -4,26 +4,26 @@ }, "description": "Manage a notification subscription: ignore, watch, or delete a notification thread subscription.", "inputSchema": { - "type": "object", - "required": [ - "notificationID", - "action" - ], "properties": { "action": { - "type": "string", "description": "Action to perform: ignore, watch, or delete the notification subscription.", "enum": [ "ignore", "watch", "delete" - ] + ], + "type": "string" }, "notificationID": { - "type": "string", - "description": "The ID of the notification thread." + "description": "The ID of the notification thread.", + "type": "string" } - } + }, + "required": [ + "notificationID", + "action" + ], + "type": "object" }, "name": "manage_notification_subscription" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap b/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap index 82ee40a89..0a4567b71 100644 --- a/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap +++ b/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap @@ -4,31 +4,31 @@ }, "description": "Manage a repository notification subscription: ignore, watch, or delete repository notifications subscription for the provided repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "action" - ], "properties": { "action": { - "type": "string", "description": "Action to perform: ignore, watch, or delete the repository notification subscription.", "enum": [ "ignore", "watch", "delete" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "The account owner of the repository." + "description": "The account owner of the repository.", + "type": "string" }, "repo": { - "type": "string", - "description": "The name of the repository." + "description": "The name of the repository.", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "action" + ], + "type": "object" }, "name": "manage_repository_notification_subscription" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/mark_all_notifications_read.snap b/pkg/github/__toolsnaps__/mark_all_notifications_read.snap index 2d45ed78d..1f5a32284 100644 --- a/pkg/github/__toolsnaps__/mark_all_notifications_read.snap +++ b/pkg/github/__toolsnaps__/mark_all_notifications_read.snap @@ -4,21 +4,21 @@ }, "description": "Mark all notifications as read", "inputSchema": { - "type": "object", "properties": { "lastReadAt": { - "type": "string", - "description": "Describes the last point that notifications were checked (optional). Default: Now" + "description": "Describes the last point that notifications were checked (optional). Default: Now", + "type": "string" }, "owner": { - "type": "string", - "description": "Optional repository owner. If provided with repo, only notifications for this repository are marked as read." + "description": "Optional repository owner. If provided with repo, only notifications for this repository are marked as read.", + "type": "string" }, "repo": { - "type": "string", - "description": "Optional repository name. If provided with owner, only notifications for this repository are marked as read." + "description": "Optional repository name. If provided with owner, only notifications for this repository are marked as read.", + "type": "string" } - } + }, + "type": "object" }, "name": "mark_all_notifications_read" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/merge_pull_request.snap b/pkg/github/__toolsnaps__/merge_pull_request.snap index 179805b3a..d0cdb2b1a 100644 --- a/pkg/github/__toolsnaps__/merge_pull_request.snap +++ b/pkg/github/__toolsnaps__/merge_pull_request.snap @@ -3,56 +3,56 @@ "title": "Merge pull request" }, "description": "Merge a pull request in a GitHub repository.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACeElEQVRIibWVTUhUYRSGn/e74+iiQih1F9Vcmj9sptylUVBYkO4jcNeuJBdFKxe1CYQokGrRKjCEdtmqwEVmtqomQWeiUdc2EBUtUufe0yLHn1KLGXtX5zvn4zz3vd8f/Gfp90Qs0drmpA6MT1EveDo1NfV92wB+KnMdo39Nfs4L7eSHD5Nz1QJcJYglWtsw+iUehAuRRjO1g+0KHLerbb4OIHnHAC1FdW129s3XmUJuwnBDoOPbA7BwHsD7QWq1HKYN5msBRCpB1AueLoSROSkciSUyj5ClhE6BLtYC8CpBqVRabNrdMmIiJdQjuUbQ1WI+d78WwIbykxnzU9np7ejlNq2YxQ4ebNtTKyCyWcEgYl55EDj/a7ihFEtkLkr0As2YxjwL+9aem00dCEYNzvnJzLDvH27aaM5y80HEnKGHKGwPnEbT6fSOvzpAmrDQnkncpC7siiUzz2QqIPu25iOuGBorTufO/AJmH0v2ajHwuoHhrQHATOH9rQPJ7IjDLgs6kZ0F6it1AzArVcZLdUE+WnYgmv/uYFmz+dxH4NJGNT+RfYLCE7F4tn0pGkxHy94AmBm8/GfAVvIs7AukUTkbj5YdYIbZ9WJh8m1lzrrbNB4/tD+QuyPsdCibF26gmM/dY/NdRDqd3rEYeN04mswYL+ZXm68DxOPxnWXXMClsp+GGhCWBTtClYj53t1qXK78oVH2XYB/mHZ0pvHsN4Cczzw3rBaoGrJ6D5ZUvN1i+kjI0LWiptjmscbC88hZZCAf2trZeq1v0UsJ6wF7UAlhxUMxPvkW6AboQLbvPcjaO+BIx11cL4I9H308eOiLRQUhpOx79/66fNKzrOCYNDm0AAAAASUVORK5CYII=", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABjElEQVRIibWVPS/DURTGnysSC0HiZdWVrZ28JDaLT8BHaBsMdjqZJDXiAzC2LF5mX6GtATGiIsGARH+Gnj9X8a/kf3uWe3Py3Oc559xz75E6bK7VAWQkzUi6lXTonHsOpgYUgAZfdgmkQpFnjHwb6AemgDpQCiWwYlEPeL4i8JCEt8vb39g67vkmPH8yA3qt5nVgCzi1jLJBBEwkBZSAdxPKAj86LYQQQCU4cYvAKzDUSYF3YC+uRIAD8sA58ACU//VuTODE1n1g+A9c3jBH1tJ1a5TeCPNrdACSCpKeJG1IepN0LKkm6dGDrkqqOOdm7dyUpDNJi865PUnqjsvEObcJHEhaljQnaV5STwvszttXbR2J441KtB4LauLKVpZpYBDYte8mHUogZTWPrAGstTtQBl6AayDX7qHZD7AALMVGDvQBV5ZyETi2qHLtMvmXWRQAk57vBKgl4fV/0+jmq56vImk0icCnAWm7pB3riGngnlADx0TW+T4yL4CxJJy/Df20mkP/TqGHfifsA7INs3X5i3+yAAAAAElFTkSuQmCC", + "theme": "dark" + } + ], "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "pullNumber" - ], "properties": { "commit_message": { - "type": "string", - "description": "Extra detail for merge commit" + "description": "Extra detail for merge commit", + "type": "string" }, "commit_title": { - "type": "string", - "description": "Title for merge commit" + "description": "Title for merge commit", + "type": "string" }, "merge_method": { - "type": "string", "description": "Merge method", "enum": [ "merge", "squash", "rebase" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "pullNumber": { - "type": "number", - "description": "Pull request number" + "description": "Pull request number", + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } - }, - "name": "merge_pull_request", - "icons": [ - { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACeElEQVRIibWVTUhUYRSGn/e74+iiQih1F9Vcmj9sptylUVBYkO4jcNeuJBdFKxe1CYQokGrRKjCEdtmqwEVmtqomQWeiUdc2EBUtUufe0yLHn1KLGXtX5zvn4zz3vd8f/Gfp90Qs0drmpA6MT1EveDo1NfV92wB+KnMdo39Nfs4L7eSHD5Nz1QJcJYglWtsw+iUehAuRRjO1g+0KHLerbb4OIHnHAC1FdW129s3XmUJuwnBDoOPbA7BwHsD7QWq1HKYN5msBRCpB1AueLoSROSkciSUyj5ClhE6BLtYC8CpBqVRabNrdMmIiJdQjuUbQ1WI+d78WwIbykxnzU9np7ejlNq2YxQ4ebNtTKyCyWcEgYl55EDj/a7ihFEtkLkr0As2YxjwL+9aem00dCEYNzvnJzLDvH27aaM5y80HEnKGHKGwPnEbT6fSOvzpAmrDQnkncpC7siiUzz2QqIPu25iOuGBorTufO/AJmH0v2ajHwuoHhrQHATOH9rQPJ7IjDLgs6kZ0F6it1AzArVcZLdUE+WnYgmv/uYFmz+dxH4NJGNT+RfYLCE7F4tn0pGkxHy94AmBm8/GfAVvIs7AukUTkbj5YdYIbZ9WJh8m1lzrrbNB4/tD+QuyPsdCibF26gmM/dY/NdRDqd3rEYeN04mswYL+ZXm68DxOPxnWXXMClsp+GGhCWBTtClYj53t1qXK78oVH2XYB/mHZ0pvHsN4Cczzw3rBaoGrJ6D5ZUvN1i+kjI0LWiptjmscbC88hZZCAf2trZeq1v0UsJ6wF7UAlhxUMxPvkW6AboQLbvPcjaO+BIx11cL4I9H308eOiLRQUhpOx79/66fNKzrOCYNDm0AAAAASUVORK5CYII=", - "mimeType": "image/png", - "theme": "light" }, - { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABjElEQVRIibWVPS/DURTGnysSC0HiZdWVrZ28JDaLT8BHaBsMdjqZJDXiAzC2LF5mX6GtATGiIsGARH+Gnj9X8a/kf3uWe3Py3Oc559xz75E6bK7VAWQkzUi6lXTonHsOpgYUgAZfdgmkQpFnjHwb6AemgDpQCiWwYlEPeL4i8JCEt8vb39g67vkmPH8yA3qt5nVgCzi1jLJBBEwkBZSAdxPKAj86LYQQQCU4cYvAKzDUSYF3YC+uRIAD8sA58ACU//VuTODE1n1g+A9c3jBH1tJ1a5TeCPNrdACSCpKeJG1IepN0LKkm6dGDrkqqOOdm7dyUpDNJi865PUnqjsvEObcJHEhaljQnaV5STwvszttXbR2J441KtB4LauLKVpZpYBDYte8mHUogZTWPrAGstTtQBl6AayDX7qHZD7AALMVGDvQBV5ZyETi2qHLtMvmXWRQAk57vBKgl4fV/0+jmq56vImk0icCnAWm7pB3riGngnlADx0TW+T4yL4CxJJy/Df20mkP/TqGHfifsA7INs3X5i3+yAAAAAElFTkSuQmCC", - "mimeType": "image/png", - "theme": "dark" - } - ] + "required": [ + "owner", + "repo", + "pullNumber" + ], + "type": "object" + }, + "name": "merge_pull_request" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap new file mode 100644 index 000000000..cb5013d74 --- /dev/null +++ b/pkg/github/__toolsnaps__/projects_get.snap @@ -0,0 +1,58 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get details of GitHub Projects resources" + }, + "description": "Get details about specific GitHub Projects resources.\nUse this tool to get details about individual projects, project fields, and project items by their unique IDs.\n", + "inputSchema": { + "properties": { + "field_id": { + "description": "The field's ID. Required for 'get_project_field' method.", + "type": "number" + }, + "fields": { + "description": "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included. Only used for 'get_project_item' method.", + "items": { + "type": "string" + }, + "type": "array" + }, + "item_id": { + "description": "The item's ID. Required for 'get_project_item' method.", + "type": "number" + }, + "method": { + "description": "The method to execute", + "enum": [ + "get_project", + "get_project_field", + "get_project_item" + ], + "type": "string" + }, + "owner": { + "description": "The owner (user or organization login). The name is not case sensitive.", + "type": "string" + }, + "owner_type": { + "description": "Owner type (user or org). If not provided, will be automatically detected.", + "enum": [ + "user", + "org" + ], + "type": "string" + }, + "project_number": { + "description": "The project's number.", + "type": "number" + } + }, + "required": [ + "method", + "owner", + "project_number" + ], + "type": "object" + }, + "name": "projects_get" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_list.snap b/pkg/github/__toolsnaps__/projects_list.snap new file mode 100644 index 000000000..f12452b5a --- /dev/null +++ b/pkg/github/__toolsnaps__/projects_list.snap @@ -0,0 +1,65 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List GitHub Projects resources" + }, + "description": "Tools for listing GitHub Projects resources.\nUse this tool to list projects for a user or organization, or list project fields and items for a specific project.\n", + "inputSchema": { + "properties": { + "after": { + "description": "Forward pagination cursor from previous pageInfo.nextCursor.", + "type": "string" + }, + "before": { + "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).", + "type": "string" + }, + "fields": { + "description": "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method.", + "items": { + "type": "string" + }, + "type": "array" + }, + "method": { + "description": "The action to perform", + "enum": [ + "list_projects", + "list_project_fields", + "list_project_items" + ], + "type": "string" + }, + "owner": { + "description": "The owner (user or organization login). The name is not case sensitive.", + "type": "string" + }, + "owner_type": { + "description": "Owner type (user or org). If not provided, will automatically try both.", + "enum": [ + "user", + "org" + ], + "type": "string" + }, + "per_page": { + "description": "Results per page (max 50)", + "type": "number" + }, + "project_number": { + "description": "The project's number. Required for 'list_project_fields' and 'list_project_items' methods.", + "type": "number" + }, + "query": { + "description": "Filter/query string. For list_projects: filter by title text and state (e.g. \"roadmap is:open\"). For list_project_items: advanced filtering using GitHub's project filtering syntax.", + "type": "string" + } + }, + "required": [ + "method", + "owner" + ], + "type": "object" + }, + "name": "projects_list" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap new file mode 100644 index 000000000..d2d871bcd --- /dev/null +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -0,0 +1,75 @@ +{ + "annotations": { + "destructiveHint": true, + "title": "Modify GitHub Project items" + }, + "description": "Add, update, or delete project items in a GitHub Project.", + "inputSchema": { + "properties": { + "issue_number": { + "description": "The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number.", + "type": "number" + }, + "item_id": { + "description": "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods.", + "type": "number" + }, + "item_owner": { + "description": "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method.", + "type": "string" + }, + "item_repo": { + "description": "The name of the repository containing the issue or pull request. Required for 'add_project_item' method.", + "type": "string" + }, + "item_type": { + "description": "The item's type, either issue or pull_request. Required for 'add_project_item' method.", + "enum": [ + "issue", + "pull_request" + ], + "type": "string" + }, + "method": { + "description": "The method to execute", + "enum": [ + "add_project_item", + "update_project_item", + "delete_project_item" + ], + "type": "string" + }, + "owner": { + "description": "The project owner (user or organization login). The name is not case sensitive.", + "type": "string" + }, + "owner_type": { + "description": "Owner type (user or org). If not provided, will be automatically detected.", + "enum": [ + "user", + "org" + ], + "type": "string" + }, + "project_number": { + "description": "The project's number.", + "type": "number" + }, + "pull_request_number": { + "description": "The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number.", + "type": "number" + }, + "updated_field": { + "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.", + "type": "object" + } + }, + "required": [ + "method", + "owner", + "project_number" + ], + "type": "object" + }, + "name": "projects_write" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap index 69b1bd901..a8591fc5c 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -5,16 +5,8 @@ }, "description": "Get information on a specific pull request in GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner", - "repo", - "pullNumber" - ], "properties": { "method": { - "type": "string", "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n", "enum": [ "get", @@ -24,32 +16,40 @@ "get_review_comments", "get_reviews", "get_comments" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "pullNumber": { - "type": "number", - "description": "Pull request number" + "description": "Pull request number", + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "method", + "owner", + "repo", + "pullNumber" + ], + "type": "object" }, "name": "pull_request_read" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/pull_request_review_write.snap b/pkg/github/__toolsnaps__/pull_request_review_write.snap index 92cc19924..7b533f472 100644 --- a/pkg/github/__toolsnaps__/pull_request_review_write.snap +++ b/pkg/github/__toolsnaps__/pull_request_review_write.snap @@ -4,53 +4,53 @@ }, "description": "Create and/or submit, delete review of a pull request.\n\nAvailable methods:\n- create: Create a new review of a pull request. If \"event\" parameter is provided, the review is submitted. If \"event\" is omitted, a pending review is created.\n- submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The \"body\" and \"event\" parameters are used when submitting the review.\n- delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request.\n", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner", - "repo", - "pullNumber" - ], "properties": { "body": { - "type": "string", - "description": "Review comment text" + "description": "Review comment text", + "type": "string" }, "commitID": { - "type": "string", - "description": "SHA of commit to review" + "description": "SHA of commit to review", + "type": "string" }, "event": { - "type": "string", "description": "Review action to perform.", "enum": [ "APPROVE", "REQUEST_CHANGES", "COMMENT" - ] + ], + "type": "string" }, "method": { - "type": "string", "description": "The write operation to perform on pull request review.", "enum": [ "create", "submit_pending", "delete_pending" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "pullNumber": { - "type": "number", - "description": "Pull request number" + "description": "Pull request number", + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "method", + "owner", + "repo", + "pullNumber" + ], + "type": "object" }, "name": "pull_request_review_write" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/push_files.snap b/pkg/github/__toolsnaps__/push_files.snap index 4db764cc9..c36c236f9 100644 --- a/pkg/github/__toolsnaps__/push_files.snap +++ b/pkg/github/__toolsnaps__/push_files.snap @@ -4,53 +4,53 @@ }, "description": "Push multiple files to a GitHub repository in a single commit", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "branch", - "files", - "message" - ], "properties": { "branch": { - "type": "string", - "description": "Branch to push to" + "description": "Branch to push to", + "type": "string" }, "files": { - "type": "array", "description": "Array of file objects to push, each object with path (string) and content (string)", "items": { - "type": "object", - "required": [ - "path", - "content" - ], "properties": { "content": { - "type": "string", - "description": "file content" + "description": "file content", + "type": "string" }, "path": { - "type": "string", - "description": "path to the file" + "description": "path to the file", + "type": "string" } - } - } + }, + "required": [ + "path", + "content" + ], + "type": "object" + }, + "type": "array" }, "message": { - "type": "string", - "description": "Commit message" + "description": "Commit message", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "branch", + "files", + "message" + ], + "type": "object" }, "name": "push_files" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/request_copilot_review.snap b/pkg/github/__toolsnaps__/request_copilot_review.snap index 0bf419d98..cd00f73fd 100644 --- a/pkg/github/__toolsnaps__/request_copilot_review.snap +++ b/pkg/github/__toolsnaps__/request_copilot_review.snap @@ -3,39 +3,39 @@ "title": "Request Copilot review" }, "description": "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer.", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "pullNumber" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "pullNumber": { - "type": "number", - "description": "Pull request number" - }, - "repo": { - "type": "string", - "description": "Repository name" - } - } - }, - "name": "request_copilot_review", "icons": [ { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC20lEQVRIidWUS4wMURSGv3O7kWmPEMRrSMzcbl1dpqtmGuOxsCKECCKxEBusSJhIWEhsWLFAbC1sWFiISBARCyQ2kzSZGaMxHokgXvGIiMH0PRZjpJqqHpb+TeX+59z//H/q5sD/DqlX9H1/zFeX2qzIKoFWYDKgwBtUymL0UkNaT3V3d3/+5wG2EGxB9TDIxGFMvhVhb9/drpN/NaDJC7MGdwJk6TDCv0Gvq0lve9R762GUNdFDLleaZNBrICGq+4yhvf9TJtP/KZNB2PrLlbBliBfRhajuAwnFVa/n8/nkxFkv3GO9oJrzgwVxdesV71ov6I2r5fxggfWCatYL9yYmUJgLPH7Q29WZ4OED6Me4wuAdeQK6MMqna9t0GuibBHFAmgZ9JMG9BhkXZWoSCDSATIq7aguBD0wBplq/tZBgYDIwKnZAs99mFRYD9vd/YK0dpcqhobM6d9haWyOULRTbAauwuNlvsxHTYP3iBnVyXGAa8BIYC3oVeAKioCtAPEE7FCOgR0ErIJdBBZgNskzh40+NF6K6s+9e91lp9osrxMnFoTSmSmPVsF+E5cB0YEDgtoMjjypd5wCy+WC9GnajhEAa4bkqV9LOHKwa9/yneYeyUqwX3AdyQ5EeVrrqro/hYL0g+ggemKh4HGbPmVu0+fB8U76lpR6XgJwZpoGUpNYiusZg1tXjkmCAav0OMTXfJC4eVYPqwbot6l4BCPqyLhd7lwMAWC/cYb3gi/UCzRaKOxsbFzVEM1iv2Ebt5v2Dm14qZbJecZf1Ah3UCrcTbbB+awHnjgHLgHeinHYqZ8aPSXWWy+XvcQZLpdKI9/0D7UbZiLIJmABckVSqo+/OrUrNgF+D8q1LEdcBrAJGAJ8ROlGeicorABWdAswE5gOjge8CF8Ad66v03IjqJb75WS0tE0YOmNWqLBGReaAzgIkMLrt3oM9UpSzCzW9pd+FpT8/7JK3/Gz8Ao5X6wtwP7N4AAAAASUVORK5CYII=", "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC20lEQVRIidWUS4wMURSGv3O7kWmPEMRrSMzcbl1dpqtmGuOxsCKECCKxEBusSJhIWEhsWLFAbC1sWFiISBARCyQ2kzSZGaMxHokgXvGIiMH0PRZjpJqqHpb+TeX+59z//H/q5sD/DqlX9H1/zFeX2qzIKoFWYDKgwBtUymL0UkNaT3V3d3/+5wG2EGxB9TDIxGFMvhVhb9/drpN/NaDJC7MGdwJk6TDCv0Gvq0lve9R762GUNdFDLleaZNBrICGq+4yhvf9TJtP/KZNB2PrLlbBliBfRhajuAwnFVa/n8/nkxFkv3GO9oJrzgwVxdesV71ov6I2r5fxggfWCatYL9yYmUJgLPH7Q29WZ4OED6Me4wuAdeQK6MMqna9t0GuibBHFAmgZ9JMG9BhkXZWoSCDSATIq7aguBD0wBplq/tZBgYDIwKnZAs99mFRYD9vd/YK0dpcqhobM6d9haWyOULRTbAauwuNlvsxHTYP3iBnVyXGAa8BIYC3oVeAKioCtAPEE7FCOgR0ErIJdBBZgNskzh40+NF6K6s+9e91lp9osrxMnFoTSmSmPVsF+E5cB0YEDgtoMjjypd5wCy+WC9GnajhEAa4bkqV9LOHKwa9/yneYeyUqwX3AdyQ5EeVrrqro/hYL0g+ggemKh4HGbPmVu0+fB8U76lpR6XgJwZpoGUpNYiusZg1tXjkmCAav0OMTXfJC4eVYPqwbot6l4BCPqyLhd7lwMAWC/cYb3gi/UCzRaKOxsbFzVEM1iv2Ebt5v2Dm14qZbJecZf1Ah3UCrcTbbB+awHnjgHLgHeinHYqZ8aPSXWWy+XvcQZLpdKI9/0D7UbZiLIJmABckVSqo+/OrUrNgF+D8q1LEdcBrAJGAJ8ROlGeicorABWdAswE5gOjge8CF8Ad66v03IjqJb75WS0tE0YOmNWqLBGReaAzgIkMLrt3oM9UpSzCzW9pd+FpT8/7JK3/Gz8Ao5X6wtwP7N4AAAAASUVORK5CYII=", "theme": "light" }, { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACCElEQVRIid2UPWsUYRSFn3dxWWJUkESiBgslFokfhehGiGClBBQx4h9IGlEh2ijYxh+gxEL/hIWwhYpF8KNZsFRJYdJEiUbjCkqisj4W+y6Mk5nd1U4PDMOce+45L3fmDvzXUDeo59WK+kb9rn5TF9R76jm1+2/NJ9QPtseSOv4nxrvVmQ6M05hRB9qZ98ZR1NRralntitdEwmw8wQ9HbS329rQKuKLW1XJO/aX6IqdWjr1Xk/y6lG4vMBdCqOacoZZ3uBBCVZ0HDrcK2AYs5ZkAuwBb1N8Dm5JEISXoAnqzOtU9QB+wVR3KCdgClDIr6kCc4c/0O1BLNnahiYpaSmmGY62e/JpCLJ4FpmmMaBHYCDwC5mmMZBQYBC7HnhvAK+B+fN4JHAM+R4+3wGQI4S7qaExtol+9o86pq+oX9Yk6ljjtGfVprK2qr9Xb6vaET109jjqb3Jac2XaM1PLNpok1Aep+G/+dfa24nADTX1EWTgOngLE2XCYKQL0DTfKex2WhXgCutxG9i/fFNlwWpgBQL6orcWyTaldToRbUA2pow61XL0WPFfXCb1HqkPowCj6q0+qIWsw7nlpUj6i31OXY+0AdbGpCRtNRGgt1AigCX4EqsJAYTR+wAzgEdAM/gApwM4TwOOm3JiARtBk4CYwAB4F+oIfGZi/HwOfAM6ASQviU5/Vv4xcBzmW2eT1nrQAAAABJRU5ErkJggg==", "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACCElEQVRIid2UPWsUYRSFn3dxWWJUkESiBgslFokfhehGiGClBBQx4h9IGlEh2ijYxh+gxEL/hIWwhYpF8KNZsFRJYdJEiUbjCkqisj4W+y6Mk5nd1U4PDMOce+45L3fmDvzXUDeo59WK+kb9rn5TF9R76jm1+2/NJ9QPtseSOv4nxrvVmQ6M05hRB9qZ98ZR1NRralntitdEwmw8wQ9HbS329rQKuKLW1XJO/aX6IqdWjr1Xk/y6lG4vMBdCqOacoZZ3uBBCVZ0HDrcK2AYs5ZkAuwBb1N8Dm5JEISXoAnqzOtU9QB+wVR3KCdgClDIr6kCc4c/0O1BLNnahiYpaSmmGY62e/JpCLJ4FpmmMaBHYCDwC5mmMZBQYBC7HnhvAK+B+fN4JHAM+R4+3wGQI4S7qaExtol+9o86pq+oX9Yk6ljjtGfVprK2qr9Xb6vaET109jjqb3Jac2XaM1PLNpok1Aep+G/+dfa24nADTX1EWTgOngLE2XCYKQL0DTfKex2WhXgCutxG9i/fFNlwWpgBQL6orcWyTaldToRbUA2pow61XL0WPFfXCb1HqkPowCj6q0+qIWsw7nlpUj6i31OXY+0AdbGpCRtNRGgt1AigCX4EqsJAYTR+wAzgEdAM/gApwM4TwOOm3JiARtBk4CYwAB4F+oIfGZi/HwOfAM6ASQviU5/Vv4xcBzmW2eT1nrQAAAABJRU5ErkJggg==", "theme": "dark" } - ] + ], + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner", + "type": "string" + }, + "pullNumber": { + "description": "Pull request number", + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "pullNumber" + ], + "type": "object" + }, + "name": "request_copilot_review" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_code.snap b/pkg/github/__toolsnaps__/search_code.snap index aebd432bf..8b5510aa6 100644 --- a/pkg/github/__toolsnaps__/search_code.snap +++ b/pkg/github/__toolsnaps__/search_code.snap @@ -5,39 +5,39 @@ }, "description": "Fast and precise code search across ALL GitHub repositories using GitHub's native search engine. Best for finding exact symbols, functions, classes, or specific code patterns.", "inputSchema": { - "type": "object", - "required": [ - "query" - ], "properties": { "order": { - "type": "string", "description": "Sort order for results", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "query": { - "type": "string", - "description": "Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more." + "description": "Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more.", + "type": "string" }, "sort": { - "type": "string", - "description": "Sort field ('indexed' only)" + "description": "Sort field ('indexed' only)", + "type": "string" } - } + }, + "required": [ + "query" + ], + "type": "object" }, "name": "search_code" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_issues.snap b/pkg/github/__toolsnaps__/search_issues.snap index f76a715fb..beaa5b737 100644 --- a/pkg/github/__toolsnaps__/search_issues.snap +++ b/pkg/github/__toolsnaps__/search_issues.snap @@ -5,44 +5,39 @@ }, "description": "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue", "inputSchema": { - "type": "object", - "required": [ - "query" - ], "properties": { "order": { - "type": "string", "description": "Sort order", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Optional repository owner. If provided with repo, only issues for this repository are listed." + "description": "Optional repository owner. If provided with repo, only issues for this repository are listed.", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "query": { - "type": "string", - "description": "Search query using GitHub issues search syntax" + "description": "Search query using GitHub issues search syntax", + "type": "string" }, "repo": { - "type": "string", - "description": "Optional repository name. If provided with owner, only issues for this repository are listed." + "description": "Optional repository name. If provided with owner, only issues for this repository are listed.", + "type": "string" }, "sort": { - "type": "string", "description": "Sort field by number of matches of categories, defaults to best match", "enum": [ "comments", @@ -56,9 +51,14 @@ "interactions", "created", "updated" - ] + ], + "type": "string" } - } + }, + "required": [ + "query" + ], + "type": "object" }, "name": "search_issues" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_orgs.snap b/pkg/github/__toolsnaps__/search_orgs.snap index 36eb948ae..9670a4be8 100644 --- a/pkg/github/__toolsnaps__/search_orgs.snap +++ b/pkg/github/__toolsnaps__/search_orgs.snap @@ -5,44 +5,44 @@ }, "description": "Find GitHub organizations by name, location, or other organization metadata. Ideal for discovering companies, open source foundations, or teams.", "inputSchema": { - "type": "object", - "required": [ - "query" - ], "properties": { "order": { - "type": "string", "description": "Sort order", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "query": { - "type": "string", - "description": "Organization search query. Examples: 'microsoft', 'location:california', 'created:\u003e=2025-01-01'. Search is automatically scoped to type:org." + "description": "Organization search query. Examples: 'microsoft', 'location:california', 'created:\u003e=2025-01-01'. Search is automatically scoped to type:org.", + "type": "string" }, "sort": { - "type": "string", "description": "Sort field by category", "enum": [ "followers", "repositories", "joined" - ] + ], + "type": "string" } - } + }, + "required": [ + "query" + ], + "type": "object" }, "name": "search_orgs" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_pull_requests.snap b/pkg/github/__toolsnaps__/search_pull_requests.snap index 2013f5c08..05376c006 100644 --- a/pkg/github/__toolsnaps__/search_pull_requests.snap +++ b/pkg/github/__toolsnaps__/search_pull_requests.snap @@ -5,44 +5,39 @@ }, "description": "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr", "inputSchema": { - "type": "object", - "required": [ - "query" - ], "properties": { "order": { - "type": "string", "description": "Sort order", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Optional repository owner. If provided with repo, only pull requests for this repository are listed." + "description": "Optional repository owner. If provided with repo, only pull requests for this repository are listed.", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "query": { - "type": "string", - "description": "Search query using GitHub pull request search syntax" + "description": "Search query using GitHub pull request search syntax", + "type": "string" }, "repo": { - "type": "string", - "description": "Optional repository name. If provided with owner, only pull requests for this repository are listed." + "description": "Optional repository name. If provided with owner, only pull requests for this repository are listed.", + "type": "string" }, "sort": { - "type": "string", "description": "Sort field by number of matches of categories, defaults to best match", "enum": [ "comments", @@ -56,9 +51,14 @@ "interactions", "created", "updated" - ] + ], + "type": "string" } - } + }, + "required": [ + "query" + ], + "type": "object" }, "name": "search_pull_requests" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_repositories.snap b/pkg/github/__toolsnaps__/search_repositories.snap index 881bc3816..8e1cb3171 100644 --- a/pkg/github/__toolsnaps__/search_repositories.snap +++ b/pkg/github/__toolsnaps__/search_repositories.snap @@ -5,50 +5,50 @@ }, "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": { - "type": "object", - "required": [ - "query" - ], "properties": { "minimal_output": { - "type": "boolean", + "default": true, "description": "Return minimal repository information (default: true). When false, returns full GitHub API repository objects.", - "default": true + "type": "boolean" }, "order": { - "type": "string", "description": "Sort order", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "query": { - "type": "string", - "description": "Repository search query. Examples: 'machine learning in:name stars:\u003e1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering." + "description": "Repository search query. Examples: 'machine learning in:name stars:\u003e1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering.", + "type": "string" }, "sort": { - "type": "string", "description": "Sort repositories by field, defaults to best match", "enum": [ "stars", "forks", "help-wanted-issues", "updated" - ] + ], + "type": "string" } - } + }, + "required": [ + "query" + ], + "type": "object" }, "name": "search_repositories" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_users.snap b/pkg/github/__toolsnaps__/search_users.snap index 293107696..bed86e8c6 100644 --- a/pkg/github/__toolsnaps__/search_users.snap +++ b/pkg/github/__toolsnaps__/search_users.snap @@ -5,44 +5,44 @@ }, "description": "Find GitHub users by username, real name, or other profile information. Useful for locating developers, contributors, or team members.", "inputSchema": { - "type": "object", - "required": [ - "query" - ], "properties": { "order": { - "type": "string", "description": "Sort order", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "query": { - "type": "string", - "description": "User search query. Examples: 'john smith', 'location:seattle', 'followers:\u003e100'. Search is automatically scoped to type:user." + "description": "User search query. Examples: 'john smith', 'location:seattle', 'followers:\u003e100'. Search is automatically scoped to type:user.", + "type": "string" }, "sort": { - "type": "string", "description": "Sort users by number of followers or repositories, or when the person joined GitHub.", "enum": [ "followers", "repositories", "joined" - ] + ], + "type": "string" } - } + }, + "required": [ + "query" + ], + "type": "object" }, "name": "search_users" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/star_repository.snap b/pkg/github/__toolsnaps__/star_repository.snap index ab1514b3d..3d7088939 100644 --- a/pkg/github/__toolsnaps__/star_repository.snap +++ b/pkg/github/__toolsnaps__/star_repository.snap @@ -3,34 +3,34 @@ "title": "Star repository" }, "description": "Star a GitHub repository", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - } - } - }, - "name": "star_repository", "icons": [ { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACG0lEQVRIidWVMWgTYRiGn+/+a21EClGrERRiTWLShrbiUETErDq7u3QRF0WhoKN06uYgKEVx1lGQLjo4OTUJ2FzSpBrEQiCkGYPm7nMwlZBe2rvaxXf6eb//fd//u/+7O0MIJDJz905MnJpvNRufg2oksHli5iwjUgXExUp9La3Vg+isoAGMyiJwBBi11XsQVBaog0zm8plfdGtApEd1LJdEpVL4sZ82UAc/cRf7zAHGPKMPg2j37eB8NnvauGYTODpQ6hjPulAur23tpTd7FePx+JhtIkvAVZ+yraJj48ciH9rtdneYhwCk03NxV5hWNAWSVLykIEngHPs/Rg/4ruiGYG2AbghSMcoXx8l/k3R6Lt4V3STEyAaE2iqTluPk66Arh2wO6Irj5OsGoNVsvIuejEVFmD8Ua+V5zSneAfTvJW83G6vHJ2LjwJV/tH9Wc4p3AYWBKWo1G6vRiZgRuH4ga3S5Vire7+d2jel2s/HxICEKT2ql4qNB3ncEbU9fhTEHGFF56cf7BrhCNmyAi/pqhr1EoQN0iGZIgEyHDUDw1dghNneB1731bR9tsA5yuZwNZPooBd4YT7PVUmGhWios2CpJEV7w5zu0g0xPO3DWAUymZ1OWUO6V3yP6uLpeWPM7XWJq9hIqS6A3ADzl4qZTqPTv2ZUYMd2tjms/NZa+rawXPvkZ76AXfDM1NXPN9eRWxHT3/Df8n/gNrfGxihYBZk0AAAAASUVORK5CYII=", "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACG0lEQVRIidWVMWgTYRiGn+/+a21EClGrERRiTWLShrbiUETErDq7u3QRF0WhoKN06uYgKEVx1lGQLjo4OTUJ2FzSpBrEQiCkGYPm7nMwlZBe2rvaxXf6eb//fd//u/+7O0MIJDJz905MnJpvNRufg2oksHli5iwjUgXExUp9La3Vg+isoAGMyiJwBBi11XsQVBaog0zm8plfdGtApEd1LJdEpVL4sZ82UAc/cRf7zAHGPKMPg2j37eB8NnvauGYTODpQ6hjPulAur23tpTd7FePx+JhtIkvAVZ+yraJj48ciH9rtdneYhwCk03NxV5hWNAWSVLykIEngHPs/Rg/4ruiGYG2AbghSMcoXx8l/k3R6Lt4V3STEyAaE2iqTluPk66Arh2wO6Irj5OsGoNVsvIuejEVFmD8Ua+V5zSneAfTvJW83G6vHJ2LjwJV/tH9Wc4p3AYWBKWo1G6vRiZgRuH4ga3S5Vire7+d2jel2s/HxICEKT2ql4qNB3ncEbU9fhTEHGFF56cf7BrhCNmyAi/pqhr1EoQN0iGZIgEyHDUDw1dghNneB1731bR9tsA5yuZwNZPooBd4YT7PVUmGhWios2CpJEV7w5zu0g0xPO3DWAUymZ1OWUO6V3yP6uLpeWPM7XWJq9hIqS6A3ADzl4qZTqPTv2ZUYMd2tjms/NZa+rawXPvkZ76AXfDM1NXPN9eRWxHT3/Df8n/gNrfGxihYBZk0AAAAASUVORK5CYII=", "theme": "light" }, { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABLElEQVRIidWVTUrDQBiG3ylddNG9+IdRMC7qpgfoWTyCnqFLF/62UMGNWw/gTdwoiMUzaCtoHxeZ4BQn6SQdEV8IZPG9zzMzCYlUIcARcFilUwW+AUyBd2DrNwSXfOciNnwVeHMEE2A9puCMnzmNBV8BXj2CCbC2LLwFDDzwPAOgVcYwFpRI6khKJe0616akxoJ1zCS9SHp0rgdJ98aYZ2PhT7ksYpC005A0lnQdGS7LHGcqMMB5yVlXzQiYP1orOYkAHwLFxw30l4AfBx1eTUk/+OkA2zUEiY9V9I7vB69mQefPBJ0aAm+nWWH4Q9KNvT/wdMN2DTTJ/lx5ZsAtsOfMJMAV8OnMTYGiBc8JUqd0B3RLZrt2Jk8aImiTfTZ6QVvOOj3baYd2/k++AC+3Yx0GcXS0AAAAAElFTkSuQmCC", "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABLElEQVRIidWVTUrDQBiG3ylddNG9+IdRMC7qpgfoWTyCnqFLF/62UMGNWw/gTdwoiMUzaCtoHxeZ4BQn6SQdEV8IZPG9zzMzCYlUIcARcFilUwW+AUyBd2DrNwSXfOciNnwVeHMEE2A9puCMnzmNBV8BXj2CCbC2LLwFDDzwPAOgVcYwFpRI6khKJe0616akxoJ1zCS9SHp0rgdJ98aYZ2PhT7ksYpC005A0lnQdGS7LHGcqMMB5yVlXzQiYP1orOYkAHwLFxw30l4AfBx1eTUk/+OkA2zUEiY9V9I7vB69mQefPBJ0aAm+nWWH4Q9KNvT/wdMN2DTTJ/lx5ZsAtsOfMJMAV8OnMTYGiBc8JUqd0B3RLZrt2Jk8aImiTfTZ6QVvOOj3baYd2/k++AC+3Yx0GcXS0AAAAAElFTkSuQmCC", "theme": "dark" } - ] + ], + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "star_repository" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/sub_issue_write.snap b/pkg/github/__toolsnaps__/sub_issue_write.snap index 1c721a2bb..1e4fcceab 100644 --- a/pkg/github/__toolsnaps__/sub_issue_write.snap +++ b/pkg/github/__toolsnaps__/sub_issue_write.snap @@ -4,48 +4,48 @@ }, "description": "Add a sub-issue to a parent issue in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner", - "repo", - "issue_number", - "sub_issue_id" - ], "properties": { "after_id": { - "type": "number", - "description": "The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)" + "description": "The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)", + "type": "number" }, "before_id": { - "type": "number", - "description": "The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)" + "description": "The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)", + "type": "number" }, "issue_number": { - "type": "number", - "description": "The number of the parent issue" + "description": "The number of the parent issue", + "type": "number" }, "method": { - "type": "string", - "description": "The action to perform on a single sub-issue\nOptions are:\n- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n\t\t\t\t" + "description": "The action to perform on a single sub-issue\nOptions are:\n- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n\t\t\t\t", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "replace_parent": { - "type": "boolean", - "description": "When true, replaces the sub-issue's current parent issue. Use with 'add' method only." + "description": "When true, replaces the sub-issue's current parent issue. Use with 'add' method only.", + "type": "boolean" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "sub_issue_id": { - "type": "number", - "description": "The ID of the sub-issue to add. ID is not the same as issue number" + "description": "The ID of the sub-issue to add. ID is not the same as issue number", + "type": "number" } - } + }, + "required": [ + "method", + "owner", + "repo", + "issue_number", + "sub_issue_id" + ], + "type": "object" }, "name": "sub_issue_write" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unstar_repository.snap b/pkg/github/__toolsnaps__/unstar_repository.snap index 709453650..2bb5d6825 100644 --- a/pkg/github/__toolsnaps__/unstar_repository.snap +++ b/pkg/github/__toolsnaps__/unstar_repository.snap @@ -4,21 +4,21 @@ }, "description": "Unstar a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "unstar_repository" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_gist.snap b/pkg/github/__toolsnaps__/update_gist.snap index a3907a88c..6d5ed100e 100644 --- a/pkg/github/__toolsnaps__/update_gist.snap +++ b/pkg/github/__toolsnaps__/update_gist.snap @@ -4,30 +4,30 @@ }, "description": "Update an existing gist", "inputSchema": { - "type": "object", - "required": [ - "gist_id", - "filename", - "content" - ], "properties": { "content": { - "type": "string", - "description": "Content for the file" + "description": "Content for the file", + "type": "string" }, "description": { - "type": "string", - "description": "Updated description of the gist" + "description": "Updated description of the gist", + "type": "string" }, "filename": { - "type": "string", - "description": "Filename to update or create" + "description": "Filename to update or create", + "type": "string" }, "gist_id": { - "type": "string", - "description": "ID of the gist to update" + "description": "ID of the gist to update", + "type": "string" } - } + }, + "required": [ + "gist_id", + "filename", + "content" + ], + "type": "object" }, "name": "update_gist" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_project_item.snap b/pkg/github/__toolsnaps__/update_project_item.snap index 8f5afaa58..987590741 100644 --- a/pkg/github/__toolsnaps__/update_project_item.snap +++ b/pkg/github/__toolsnaps__/update_project_item.snap @@ -4,40 +4,40 @@ }, "description": "Update a specific Project item for a user or org", "inputSchema": { - "type": "object", - "required": [ - "owner_type", - "owner", - "project_number", - "item_id", - "updated_field" - ], "properties": { "item_id": { - "type": "number", - "description": "The unique identifier of the project item. This is not the issue or pull request ID." + "description": "The unique identifier of the project item. This is not the issue or pull request ID.", + "type": "number" }, "owner": { - "type": "string", - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." + "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", + "type": "string" }, "owner_type": { - "type": "string", "description": "Owner type", "enum": [ "user", "org" - ] + ], + "type": "string" }, "project_number": { - "type": "number", - "description": "The project's number." + "description": "The project's number.", + "type": "number" }, "updated_field": { - "type": "object", - "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}" + "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}", + "type": "object" } - } + }, + "required": [ + "owner_type", + "owner", + "project_number", + "item_id", + "updated_field" + ], + "type": "object" }, "name": "update_project_item" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_pull_request.snap b/pkg/github/__toolsnaps__/update_pull_request.snap index 6dec2c01f..ef330188f 100644 --- a/pkg/github/__toolsnaps__/update_pull_request.snap +++ b/pkg/github/__toolsnaps__/update_pull_request.snap @@ -4,61 +4,61 @@ }, "description": "Update an existing pull request in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "pullNumber" - ], "properties": { "base": { - "type": "string", - "description": "New base branch name" + "description": "New base branch name", + "type": "string" }, "body": { - "type": "string", - "description": "New description" + "description": "New description", + "type": "string" }, "draft": { - "type": "boolean", - "description": "Mark pull request as draft (true) or ready for review (false)" + "description": "Mark pull request as draft (true) or ready for review (false)", + "type": "boolean" }, "maintainer_can_modify": { - "type": "boolean", - "description": "Allow maintainer edits" + "description": "Allow maintainer edits", + "type": "boolean" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "pullNumber": { - "type": "number", - "description": "Pull request number to update" + "description": "Pull request number to update", + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "reviewers": { - "type": "array", "description": "GitHub usernames to request reviews from", "items": { "type": "string" - } + }, + "type": "array" }, "state": { - "type": "string", "description": "New state", "enum": [ "open", "closed" - ] + ], + "type": "string" }, "title": { - "type": "string", - "description": "New title" + "description": "New title", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "pullNumber" + ], + "type": "object" }, "name": "update_pull_request" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_pull_request_branch.snap b/pkg/github/__toolsnaps__/update_pull_request_branch.snap index 9be1cb002..a84ac414d 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_branch.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_branch.snap @@ -4,30 +4,30 @@ }, "description": "Update the branch of a pull request with the latest changes from the base branch.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "pullNumber" - ], "properties": { "expectedHeadSha": { - "type": "string", - "description": "The expected SHA of the pull request's HEAD ref" + "description": "The expected SHA of the pull request's HEAD ref", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "pullNumber": { - "type": "number", - "description": "Pull request number" + "description": "Pull request number", + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "pullNumber" + ], + "type": "object" }, "name": "update_pull_request_branch" } \ No newline at end of file diff --git a/pkg/github/actions.go b/pkg/github/actions.go index e4e17224d..d1d9a5aa4 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -13,6 +13,7 @@ import ( buffer "github.com/github/github-mcp-server/pkg/buffer" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -237,7 +238,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an Type: "string", Description: `The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: - Do not provide any resource ID for 'list_workflows' method. -- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method. +- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method, or omit to list all workflow runs in the repository. - Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods. `, }, @@ -324,6 +325,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an Required: []string{"method", "owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -360,18 +362,18 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an switch method { case actionsMethodListWorkflows: // Do nothing, no resource ID needed + case actionsMethodListWorkflowRuns: + // resource_id is optional for list_workflow_runs + // If not provided, list all workflow runs in the repository default: if resourceID == "" { return utils.NewToolResultError(fmt.Sprintf("missing required parameter for method %s: resource_id", method)), nil, nil } - // For list_workflow_runs, resource_id could be a filename or numeric ID - // For other actions, resource ID must be an integer - if method != actionsMethodListWorkflowRuns { - resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) - if parseErr != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil - } + // resource ID must be an integer for jobs and artifacts + resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) + if parseErr != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil } } @@ -441,6 +443,7 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an Required: []string{"method", "owner", "repo", "resource_id"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -553,6 +556,7 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"method", "owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -666,6 +670,7 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -833,7 +838,9 @@ func listWorkflowRuns(ctx context.Context, client *github.Client, args map[strin var workflowRuns *github.WorkflowRuns var resp *github.Response - if workflowIDInt, parseErr := strconv.ParseInt(resourceID, 10, 64); parseErr == nil { + if resourceID == "" { + workflowRuns, resp, err = client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, listWorkflowRunsOptions) + } else if workflowIDInt, parseErr := strconv.ParseInt(resourceID, 10, 64); parseErr == nil { workflowRuns, resp, err = client.Actions.ListWorkflowRunsByID(ctx, owner, repo, workflowIDInt, listWorkflowRunsOptions) } else { workflowRuns, resp, err = client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, resourceID, listWorkflowRunsOptions) diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index e505381f5..fe0f5575d 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -10,7 +10,6 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -43,32 +42,29 @@ func Test_ActionsList_ListWorkflows(t *testing.T) { }{ { name: "successful workflow list", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsWorkflowsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - workflows := &github.Workflows{ - TotalCount: github.Ptr(2), - Workflows: []*github.Workflow{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("CI"), - Path: github.Ptr(".github/workflows/ci.yml"), - State: github.Ptr("active"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("Deploy"), - Path: github.Ptr(".github/workflows/deploy.yml"), - State: github.Ptr("active"), - }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsWorkflowsByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + workflows := &github.Workflows{ + TotalCount: github.Ptr(2), + Workflows: []*github.Workflow{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("CI"), + Path: github.Ptr(".github/workflows/ci.yml"), + State: github.Ptr("active"), + }, + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("Deploy"), + Path: github.Ptr(".github/workflows/deploy.yml"), + State: github.Ptr("active"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(workflows) - }), - ), - ), + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(workflows) + }), + }), requestArgs: map[string]any{ "method": "list_workflows", "owner": "owner", @@ -78,7 +74,7 @@ func Test_ActionsList_ListWorkflows(t *testing.T) { }, { name: "missing required parameter method", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -122,26 +118,23 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { toolDef := ActionsList(translations.NullTranslationHelper) t.Run("successful workflow runs list", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - runs := &github.WorkflowRuns{ - TotalCount: github.Ptr(1), - WorkflowRuns: []*github.WorkflowRun{ - { - ID: github.Ptr(int64(123)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - }, + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(1), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(123)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(runs) - }), - ), - ) + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -167,8 +160,30 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { assert.NotNil(t, response.TotalCount) }) - t.Run("missing resource_id for list_workflow_runs", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + t.Run("list all workflow runs without resource_id", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(2), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(123)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + }, + { + ID: github.Ptr(int64(456)), + Name: github.Ptr("Deploy"), + Status: github.Ptr("in_progress"), + Conclusion: nil, + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -184,10 +199,13 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) - require.True(t, result.IsError) + require.False(t, result.IsError) textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "missing required parameter") + var response github.WorkflowRuns + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, 2, *response.TotalCount) }) } @@ -210,21 +228,18 @@ func Test_ActionsGet_GetWorkflow(t *testing.T) { toolDef := ActionsGet(translations.NullTranslationHelper) t.Run("successful workflow get", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsWorkflowsByOwnerByRepoByWorkflowId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - workflow := &github.Workflow{ - ID: github.Ptr(int64(1)), - Name: github.Ptr("CI"), - Path: github.Ptr(".github/workflows/ci.yml"), - State: github.Ptr("active"), - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(workflow) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsWorkflowsByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + workflow := &github.Workflow{ + ID: github.Ptr(int64(1)), + Name: github.Ptr("CI"), + Path: github.Ptr(".github/workflows/ci.yml"), + State: github.Ptr("active"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(workflow) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -256,21 +271,18 @@ func Test_ActionsGet_GetWorkflowRun(t *testing.T) { toolDef := ActionsGet(translations.NullTranslationHelper) t.Run("successful workflow run get", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - run := &github.WorkflowRun{ - ID: github.Ptr(int64(12345)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(run) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + run := &github.WorkflowRun{ + ID: github.Ptr(int64(12345)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(run) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -327,14 +339,11 @@ func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) { }{ { name: "successful workflow run", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + }), requestArgs: map[string]any{ "method": "run_workflow", "owner": "owner", @@ -346,7 +355,7 @@ func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) { }, { name: "missing required parameter workflow_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "method": "run_workflow", "owner": "owner", @@ -358,7 +367,7 @@ func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) { }, { name: "missing required parameter ref", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "method": "run_workflow", "owner": "owner", @@ -403,17 +412,11 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { toolDef := ActionsRunTrigger(translations.NullTranslationHelper) t.Run("successful workflow run cancellation", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/runs/12345/cancel", - Method: "POST", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusAccepted) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposActionsRunsCancelByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -440,17 +443,11 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { }) t.Run("conflict when cancelling a workflow run", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/runs/12345/cancel", - Method: "POST", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusConflict) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposActionsRunsCancelByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -474,7 +471,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { }) t.Run("missing run_id for non-run_workflow methods", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -521,15 +518,12 @@ func Test_ActionsGetJobLogs_SingleJob(t *testing.T) { toolDef := ActionsGetJobLogs(translations.NullTranslationHelper) t.Run("successful single job logs with URL", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", "https://github.com/logs/job/123") - w.WriteHeader(http.StatusFound) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/123") + w.WriteHeader(http.StatusFound) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -562,42 +556,36 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { toolDef := ActionsGetJobLogs(translations.NullTranslationHelper) t.Run("successful failed jobs logs", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jobs := &github.Jobs{ - TotalCount: github.Ptr(3), - Jobs: []*github.WorkflowJob{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("test-job-1"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test-job-2"), - Conclusion: github.Ptr("failure"), - }, - { - ID: github.Ptr(int64(3)), - Name: github.Ptr("test-job-3"), - Conclusion: github.Ptr("failure"), - }, + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(3), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("test-job-1"), + Conclusion: github.Ptr("success"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(jobs) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:]) - w.WriteHeader(http.StatusFound) - }), - ), - ) + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("test-job-2"), + Conclusion: github.Ptr("failure"), + }, + { + ID: github.Ptr(int64(3)), + Name: github.Ptr("test-job-3"), + Conclusion: github.Ptr("failure"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:]) + w.WriteHeader(http.StatusFound) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -627,30 +615,27 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { }) t.Run("no failed jobs found", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jobs := &github.Jobs{ - TotalCount: github.Ptr(2), - Jobs: []*github.WorkflowJob{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("test-job-1"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test-job-2"), - Conclusion: github.Ptr("success"), - }, + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(2), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("test-job-1"), + Conclusion: github.Ptr("success"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(jobs) - }), - ), - ) + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("test-job-2"), + Conclusion: github.Ptr("success"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index 5e25d0501..ccc00661a 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -8,6 +8,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -44,6 +45,7 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server Required: []string{"owner", "repo", "alertNumber"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -135,6 +137,7 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index e0df82c88..29fa2925d 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -7,6 +7,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/jsonschema-go/jsonschema" @@ -51,6 +52,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { // OpenAI strict mode requires the properties field to be present. InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), }, + nil, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -129,6 +131,7 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, }, + []scopes.Scope{scopes.ReadOrg}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { user, err := OptionalParam[string](args, "user") if err != nil { @@ -231,6 +234,7 @@ func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"org", "team_slug"}, }, }, + []scopes.Scope{scopes.ReadOrg}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { org, err := RequiredParam[string](args, "org") if err != nil { diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index db6352dab..b6b2eeaba 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -9,6 +9,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -45,6 +46,7 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo Required: []string{"owner", "repo", "alertNumber"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -128,6 +130,7 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index d23e993c3..15d807a24 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -3,10 +3,13 @@ package github import ( "context" "errors" + "fmt" + "os" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v79/github" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -76,6 +79,9 @@ type ToolDependencies interface { // GetContentWindowSize returns the content window size for log truncation GetContentWindowSize() int + + // IsFeatureEnabled checks if a feature flag is enabled. + IsFeatureEnabled(ctx context.Context, flagName string) bool } // BaseDeps is the standard implementation of ToolDependencies for the local server. @@ -92,8 +98,14 @@ type BaseDeps struct { T translations.TranslationHelperFunc Flags FeatureFlags ContentWindowSize int + + // Feature flag checker for runtime checks + featureChecker inventory.FeatureFlagChecker } +// Compile-time assertion to verify that BaseDeps implements the ToolDependencies interface. +var _ ToolDependencies = (*BaseDeps)(nil) + // NewBaseDeps creates a BaseDeps with the provided clients and configuration. func NewBaseDeps( client *gogithub.Client, @@ -103,6 +115,7 @@ func NewBaseDeps( t translations.TranslationHelperFunc, flags FeatureFlags, contentWindowSize int, + featureChecker inventory.FeatureFlagChecker, ) *BaseDeps { return &BaseDeps{ Client: client, @@ -112,6 +125,7 @@ func NewBaseDeps( T: t, Flags: flags, ContentWindowSize: contentWindowSize, + featureChecker: featureChecker, } } @@ -142,17 +156,47 @@ func (d BaseDeps) GetFlags() FeatureFlags { return d.Flags } // GetContentWindowSize implements ToolDependencies. func (d BaseDeps) GetContentWindowSize() int { return d.ContentWindowSize } +// IsFeatureEnabled checks if a feature flag is enabled. +// Returns false if the feature checker is nil, flag name is empty, or an error occurs. +// This allows tools to conditionally change behavior based on feature flags. +func (d BaseDeps) IsFeatureEnabled(ctx context.Context, flagName string) bool { + if d.featureChecker == nil || flagName == "" { + return false + } + + enabled, err := d.featureChecker(ctx, flagName) + if err != nil { + // Log error but don't fail the tool - treat as disabled + fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", flagName, err) + return false + } + + return enabled +} + // NewTool creates a ServerTool that retrieves ToolDependencies from context at call time. // This avoids creating closures at registration time, which is important for performance // in servers that create a new server instance per request (like the remote server). // // The handler function receives deps extracted from context via MustDepsFromContext. // Ensure ContextWithDeps is called to inject deps before any tool handlers are invoked. -func NewTool[In, Out any](toolset inventory.ToolsetMetadata, tool mcp.Tool, handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error)) inventory.ServerTool { - return inventory.NewServerToolWithContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) { +// +// requiredScopes specifies the minimum OAuth scopes needed for this tool. +// AcceptedScopes are automatically derived using the scope hierarchy (e.g., if +// public_repo is required, repo is also accepted since repo grants public_repo). +func NewTool[In, Out any]( + toolset inventory.ToolsetMetadata, + tool mcp.Tool, + requiredScopes []scopes.Scope, + handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error), +) inventory.ServerTool { + st := inventory.NewServerToolWithContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) { deps := MustDepsFromContext(ctx) return handler(ctx, deps, req, args) }) + st.RequiredScopes = scopes.ToStringSlice(requiredScopes...) + st.AcceptedScopes = scopes.ExpandScopes(requiredScopes...) + return st } // NewToolFromHandler creates a ServerTool that retrieves ToolDependencies from context at call time. @@ -160,9 +204,20 @@ func NewTool[In, Out any](toolset inventory.ToolsetMetadata, tool mcp.Tool, hand // // The handler function receives deps extracted from context via MustDepsFromContext. // Ensure ContextWithDeps is called to inject deps before any tool handlers are invoked. -func NewToolFromHandler(toolset inventory.ToolsetMetadata, tool mcp.Tool, handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest) (*mcp.CallToolResult, error)) inventory.ServerTool { - return inventory.NewServerToolWithRawContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// +// requiredScopes specifies the minimum OAuth scopes needed for this tool. +// AcceptedScopes are automatically derived using the scope hierarchy. +func NewToolFromHandler( + toolset inventory.ToolsetMetadata, + tool mcp.Tool, + requiredScopes []scopes.Scope, + handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest) (*mcp.CallToolResult, error), +) inventory.ServerTool { + st := inventory.NewServerToolWithRawContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { deps := MustDepsFromContext(ctx) return handler(ctx, deps, req) }) + st.RequiredScopes = scopes.ToStringSlice(requiredScopes...) + st.AcceptedScopes = scopes.ExpandScopes(requiredScopes...) + return st } diff --git a/pkg/github/dependencies_test.go b/pkg/github/dependencies_test.go new file mode 100644 index 000000000..d13160d4c --- /dev/null +++ b/pkg/github/dependencies_test.go @@ -0,0 +1,108 @@ +package github_test + +import ( + "context" + "errors" + "testing" + + "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/stretchr/testify/assert" +) + +func TestIsFeatureEnabled_WithEnabledFlag(t *testing.T) { + t.Parallel() + + // Create a feature checker that returns true for "test_flag" + checker := func(_ context.Context, flagName string) (bool, error) { + return flagName == "test_flag", nil + } + + // Create deps with the checker using NewBaseDeps + deps := github.NewBaseDeps( + nil, // client + nil, // gqlClient + nil, // rawClient + nil, // repoAccessCache + translations.NullTranslationHelper, + github.FeatureFlags{}, + 0, // contentWindowSize + checker, // featureChecker + ) + + // Test enabled flag + result := deps.IsFeatureEnabled(context.Background(), "test_flag") + assert.True(t, result, "Expected test_flag to be enabled") + + // Test disabled flag + result = deps.IsFeatureEnabled(context.Background(), "other_flag") + assert.False(t, result, "Expected other_flag to be disabled") +} + +func TestIsFeatureEnabled_WithoutChecker(t *testing.T) { + t.Parallel() + + // Create deps without feature checker (nil) + deps := github.NewBaseDeps( + nil, // client + nil, // gqlClient + nil, // rawClient + nil, // repoAccessCache + translations.NullTranslationHelper, + github.FeatureFlags{}, + 0, // contentWindowSize + nil, // featureChecker (nil) + ) + + // Should return false when checker is nil + result := deps.IsFeatureEnabled(context.Background(), "any_flag") + assert.False(t, result, "Expected false when checker is nil") +} + +func TestIsFeatureEnabled_EmptyFlagName(t *testing.T) { + t.Parallel() + + // Create a feature checker + checker := func(_ context.Context, _ string) (bool, error) { + return true, nil + } + + deps := github.NewBaseDeps( + nil, // client + nil, // gqlClient + nil, // rawClient + nil, // repoAccessCache + translations.NullTranslationHelper, + github.FeatureFlags{}, + 0, // contentWindowSize + checker, // featureChecker + ) + + // Should return false for empty flag name + result := deps.IsFeatureEnabled(context.Background(), "") + assert.False(t, result, "Expected false for empty flag name") +} + +func TestIsFeatureEnabled_CheckerError(t *testing.T) { + t.Parallel() + + // Create a feature checker that returns an error + checker := func(_ context.Context, _ string) (bool, error) { + return false, errors.New("checker error") + } + + deps := github.NewBaseDeps( + nil, // client + nil, // gqlClient + nil, // rawClient + nil, // repoAccessCache + translations.NullTranslationHelper, + github.FeatureFlags{}, + 0, // contentWindowSize + checker, // featureChecker + ) + + // Should return false and log error (not crash) + result := deps.IsFeatureEnabled(context.Background(), "error_flag") + assert.False(t, result, "Expected false when checker returns error") +} diff --git a/pkg/github/deprecated_tool_aliases.go b/pkg/github/deprecated_tool_aliases.go index 63394770e..4415731fb 100644 --- a/pkg/github/deprecated_tool_aliases.go +++ b/pkg/github/deprecated_tool_aliases.go @@ -28,4 +28,15 @@ var DeprecatedToolAliases = map[string]string{ "rerun_failed_jobs": "actions_run_trigger", "cancel_workflow_run": "actions_run_trigger", "delete_workflow_run_logs": "actions_run_trigger", + + // Projects tools consolidated + "list_projects": "projects_list", + "list_project_fields": "projects_list", + "list_project_items": "projects_list", + "get_project": "projects_get", + "get_project_field": "projects_get", + "get_project_item": "projects_get", + "add_project_item": "projects_write", + "update_project_item": "projects_write", + "delete_project_item": "projects_write", } diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index c891ba294..c03670818 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" @@ -161,6 +162,7 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -303,6 +305,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "discussionNumber"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Decode params var params struct { @@ -406,6 +409,7 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve Required: []string{"owner", "repo", "discussionNumber"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Decode params var params struct { @@ -528,6 +532,7 @@ func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.Se Required: []string{"owner"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/dynamic_tools_test.go b/pkg/github/dynamic_tools_test.go index 8d12b78c2..3e63c5d7b 100644 --- a/pkg/github/dynamic_tools_test.go +++ b/pkg/github/dynamic_tools_test.go @@ -25,9 +25,10 @@ func createDynamicRequest(args map[string]any) *mcp.CallToolRequest { func TestDynamicTools_ListAvailableToolsets(t *testing.T) { // Build a registry with no toolsets enabled (dynamic mode) - reg := NewInventory(translations.NullTranslationHelper). + reg, err := NewInventory(translations.NullTranslationHelper). WithToolsets([]string{}). Build() + require.NoError(t, err) // Create a mock server server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) @@ -73,9 +74,10 @@ func TestDynamicTools_ListAvailableToolsets(t *testing.T) { func TestDynamicTools_GetToolsetTools(t *testing.T) { // Build a registry with no toolsets enabled (dynamic mode) - reg := NewInventory(translations.NullTranslationHelper). + reg, err := NewInventory(translations.NullTranslationHelper). WithToolsets([]string{}). Build() + require.NoError(t, err) // Create a mock server server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) @@ -122,9 +124,10 @@ func TestDynamicTools_GetToolsetTools(t *testing.T) { func TestDynamicTools_EnableToolset(t *testing.T) { // Build a registry with no toolsets enabled (dynamic mode) - reg := NewInventory(translations.NullTranslationHelper). + reg, err := NewInventory(translations.NullTranslationHelper). WithToolsets([]string{}). Build() + require.NoError(t, err) // Create a mock server server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) @@ -133,7 +136,7 @@ func TestDynamicTools_EnableToolset(t *testing.T) { deps := DynamicToolDependencies{ Server: server, Inventory: reg, - ToolDeps: NewBaseDeps(nil, nil, nil, nil, translations.NullTranslationHelper, FeatureFlags{}, 0), + ToolDeps: NewBaseDeps(nil, nil, nil, nil, translations.NullTranslationHelper, FeatureFlags{}, 0, nil), T: translations.NullTranslationHelper, } @@ -170,9 +173,10 @@ func TestDynamicTools_EnableToolset(t *testing.T) { func TestDynamicTools_EnableToolset_InvalidToolset(t *testing.T) { // Build a registry with no toolsets enabled (dynamic mode) - reg := NewInventory(translations.NullTranslationHelper). + reg, err := NewInventory(translations.NullTranslationHelper). WithToolsets([]string{}). Build() + require.NoError(t, err) // Create a mock server server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) @@ -203,7 +207,8 @@ func TestDynamicTools_EnableToolset_InvalidToolset(t *testing.T) { func TestDynamicTools_ToolsetsEnum(t *testing.T) { // Build a registry - reg := NewInventory(translations.NullTranslationHelper).Build() + reg, err := NewInventory(translations.NullTranslationHelper).Build() + require.NoError(t, err) // Get tools to verify they have proper enum values tools := DynamicTools(reg) diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index 047042e44..fd06a659b 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -3,4 +3,5 @@ package github // FeatureFlags defines runtime feature toggles that adjust tool behavior. type FeatureFlags struct { LockdownMode bool + InsidersMode bool } diff --git a/pkg/github/feature_flags_test.go b/pkg/github/feature_flags_test.go new file mode 100644 index 000000000..498c6e487 --- /dev/null +++ b/pkg/github/feature_flags_test.go @@ -0,0 +1,198 @@ +package github + +import ( + "context" + "encoding/json" + "testing" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/utils" +) + +// RemoteMCPEnthusiasticGreeting is a dummy test feature flag . +const RemoteMCPEnthusiasticGreeting = "remote_mcp_enthusiastic_greeting" + +// FeatureChecker is an interface for checking if a feature flag is enabled. +type FeatureChecker interface { + // IsFeatureEnabled checks if a feature flag is enabled. + IsFeatureEnabled(ctx context.Context, flagName string) bool +} + +// HelloWorld returns a simple greeting tool that demonstrates feature flag conditional behavior. +// This tool is for testing and demonstration purposes only. +func HelloWorldTool(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataContext, // Use existing "context" toolset + mcp.Tool{ + Name: "hello_world", + Description: t("TOOL_HELLO_WORLD_DESCRIPTION", "A simple greeting tool that demonstrates feature flag conditional behavior"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_HELLO_WORLD_TITLE", "Hello World"), + ReadOnlyHint: true, + }, + }, + []scopes.Scope{}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { + + // Check feature flag to determine greeting style + greeting := "Hello, world!" + if deps.IsFeatureEnabled(ctx, RemoteMCPEnthusiasticGreeting) { + greeting += " Welcome to the future of MCP! 🎉" + } + if deps.GetFlags().InsidersMode { + greeting += " Experimental features are enabled! 🚀" + } + + // Build response + response := map[string]any{ + "greeting": greeting, + } + + jsonBytes, err := json.Marshal(response) + if err != nil { + return utils.NewToolResultError("failed to marshal response"), nil, nil + } + + return utils.NewToolResultText(string(jsonBytes)), nil, nil + }, + ) +} + +func TestHelloWorld_ConditionalBehavior_Featureflag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + featureFlagEnabled bool + inputName string + expectedGreeting string + }{ + { + name: "Feature flag disabled - default greeting", + featureFlagEnabled: false, + expectedGreeting: "Hello, world!", + }, + { + name: "Feature flag enabled - enthusiastic greeting", + featureFlagEnabled: true, + expectedGreeting: "Hello, world! Welcome to the future of MCP! 🎉", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create feature checker based on test case + checker := func(_ context.Context, flagName string) (bool, error) { + if flagName == RemoteMCPEnthusiasticGreeting { + return tt.featureFlagEnabled, nil + } + return false, nil + } + + // Create deps with the checker + deps := NewBaseDeps( + nil, nil, nil, nil, + translations.NullTranslationHelper, + FeatureFlags{}, + 0, + checker, + ) + + // Get the tool and its handler + tool := HelloWorldTool(translations.NullTranslationHelper) + handler := tool.Handler(deps) + + // Call the handler with deps in context + ctx := ContextWithDeps(context.Background(), deps) + result, err := handler(ctx, &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Arguments: json.RawMessage(`{}`), + }, + }) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.Content, 1) + + // Parse the response - should be TextContent + textContent, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok, "expected content to be TextContent") + + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + // Verify the greeting matches expected based on feature flag + assert.Equal(t, tt.expectedGreeting, response["greeting"]) + }) + } +} + +func TestHelloWorld_ConditionalBehavior_Config(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + insidersMode bool + expectedGreeting string + }{ + { + name: "Experimental disabled - default greeting", + insidersMode: false, + expectedGreeting: "Hello, world!", + }, + { + name: "Experimental enabled - experimental greeting", + insidersMode: true, + expectedGreeting: "Hello, world! Experimental features are enabled! 🚀", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create deps with the checker + deps := NewBaseDeps( + nil, nil, nil, nil, + translations.NullTranslationHelper, + FeatureFlags{InsidersMode: tt.insidersMode}, + 0, + nil, + ) + + // Get the tool and its handler + tool := HelloWorldTool(translations.NullTranslationHelper) + handler := tool.Handler(deps) + + // Call the handler with deps in context + ctx := ContextWithDeps(context.Background(), deps) + result, err := handler(ctx, &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Arguments: json.RawMessage(`{}`), + }, + }) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.Content, 1) + + // Parse the response - should be TextContent + textContent, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok, "expected content to be TextContent") + + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + // Verify the greeting matches expected based on feature flag + assert.Equal(t, tt.expectedGreeting, response["greeting"]) + }) + } +} diff --git a/pkg/github/gists.go b/pkg/github/gists.go index 4d741b88d..0f43ebdf9 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -9,6 +9,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -41,6 +42,7 @@ func ListGists(t translations.TranslationHelperFunc) inventory.ServerTool { }, }), }, + nil, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { username, err := OptionalParam[string](args, "username") if err != nil { @@ -124,6 +126,7 @@ func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"gist_id"}, }, }, + nil, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { gistID, err := RequiredParam[string](args, "gist_id") if err != nil { @@ -194,6 +197,7 @@ func CreateGist(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"filename", "content"}, }, }, + []scopes.Scope{scopes.Gist}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { description, err := OptionalParam[string](args, "description") if err != nil { @@ -295,6 +299,7 @@ func UpdateGist(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"gist_id", "filename", "content"}, }, }, + []scopes.Scope{scopes.Gist}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { gistID, err := RequiredParam[string](args, "gist_id") if err != nil { diff --git a/pkg/github/git.go b/pkg/github/git.go index 7b93c3675..ec7159b9b 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -8,6 +8,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -76,6 +77,7 @@ func GetRepositoryTree(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 56a236660..0bb73008e 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -11,7 +11,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" + testifymock "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -41,9 +41,9 @@ const ( // Git endpoints GetReposGitTreesByOwnerByRepoByTree = "GET /repos/{owner}/{repo}/git/trees/{tree}" - GetReposGitRefByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/git/ref/{ref}" + GetReposGitRefByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/git/ref/{ref:.*}" PostReposGitRefsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/refs" - PatchReposGitRefsByOwnerByRepoByRef = "PATCH /repos/{owner}/{repo}/git/refs/{ref}" + PatchReposGitRefsByOwnerByRepoByRef = "PATCH /repos/{owner}/{repo}/git/refs/{ref:.*}" GetReposGitCommitsByOwnerByRepoByCommitSHA = "GET /repos/{owner}/{repo}/git/commits/{commit_sha}" PostReposGitCommitsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/commits" GetReposGitTagsByOwnerByRepoByTagSHA = "GET /repos/{owner}/{repo}/git/tags/{tag_sha}" @@ -59,7 +59,7 @@ const ( PatchReposIssuesByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}" GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues" PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues" - DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issues" + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issue" PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority" // Pull request endpoints @@ -118,6 +118,7 @@ const ( GetReposActionsWorkflowsByOwnerByRepoByWorkflowID = "GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}" PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowID = "POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches" GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowID = "GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs" + GetReposActionsRunsByOwnerByRepo = "GET /repos/{owner}/{repo}/actions/runs" GetReposActionsRunsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}" GetReposActionsRunsLogsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}/logs" GetReposActionsRunsJobsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs" @@ -132,8 +133,8 @@ const ( // Search endpoints GetSearchCode = "GET /search/code" GetSearchIssues = "GET /search/issues" - GetSearchRepositories = "GET /search/repositories" GetSearchUsers = "GET /search/users" + GetSearchRepositories = "GET /search/repositories" // Raw content endpoints (used for GitHub raw content API, not standard API) // These are used with the raw content client that interacts with raw.githubusercontent.com @@ -141,6 +142,31 @@ const ( GetRawReposContentsByOwnerByRepoByBranchByPath = "GET /{owner}/{repo}/refs/heads/{branch}/{path:.*}" GetRawReposContentsByOwnerByRepoByTagByPath = "GET /{owner}/{repo}/refs/tags/{tag}/{path:.*}" GetRawReposContentsByOwnerByRepoBySHAByPath = "GET /{owner}/{repo}/{sha}/{path:.*}" + + // Projects (ProjectsV2) endpoints + // Organization-scoped + GetOrgsProjectsV2 = "GET /orgs/{org}/projectsV2" + GetOrgsProjectsV2ByProject = "GET /orgs/{org}/projectsV2/{project}" + GetOrgsProjectsV2FieldsByProject = "GET /orgs/{org}/projectsV2/{project}/fields" + GetOrgsProjectsV2FieldsByProjectByFieldID = "GET /orgs/{org}/projectsV2/{project}/fields/{field_id}" + GetOrgsProjectsV2ItemsByProject = "GET /orgs/{org}/projectsV2/{project}/items" + GetOrgsProjectsV2ItemsByProjectByItemID = "GET /orgs/{org}/projectsV2/{project}/items/{item_id}" + PostOrgsProjectsV2ItemsByProject = "POST /orgs/{org}/projectsV2/{project}/items" + PatchOrgsProjectsV2ItemsByProjectByItemID = "PATCH /orgs/{org}/projectsV2/{project}/items/{item_id}" + DeleteOrgsProjectsV2ItemsByProjectByItemID = "DELETE /orgs/{org}/projectsV2/{project}/items/{item_id}" + // User-scoped + GetUsersProjectsV2ByUsername = "GET /users/{username}/projectsV2" + GetUsersProjectsV2ByUsernameByProject = "GET /users/{username}/projectsV2/{project}" + GetUsersProjectsV2FieldsByUsernameByProject = "GET /users/{username}/projectsV2/{project}/fields" + GetUsersProjectsV2FieldsByUsernameByProjectByFieldID = "GET /users/{username}/projectsV2/{project}/fields/{field_id}" + GetUsersProjectsV2ItemsByUsernameByProject = "GET /users/{username}/projectsV2/{project}/items" + GetUsersProjectsV2ItemsByUsernameByProjectByItemID = "GET /users/{username}/projectsV2/{project}/items/{item_id}" + PostUsersProjectsV2ItemsByUsernameByProject = "POST /users/{username}/projectsV2/{project}/items" + PatchUsersProjectsV2ItemsByUsernameByProjectByItemID = "PATCH /users/{username}/projectsV2/{project}/items/{item_id}" + DeleteUsersProjectsV2ItemsByUsernameByProjectByItemID = "DELETE /users/{username}/projectsV2/{project}/items/{item_id}" + + // Organization issue types endpoints + GetOrgsIssueTypesByOrg = "GET /orgs/{org}/issue-types" ) type expectations struct { @@ -408,7 +434,7 @@ func getResourceResult(t *testing.T, result *mcp.CallToolResult) *mcp.ResourceCo // MockRoundTripper is a mock HTTP transport using testify/mock type MockRoundTripper struct { - mock.Mock + testifymock.Mock handlers map[string]http.HandlerFunc } @@ -564,6 +590,64 @@ func MockHTTPClientWithHandlers(handlers map[string]http.HandlerFunc) *http.Clie return &http.Client{Transport: transport} } +// Compatibility helpers to replace github.com/migueleliasweb/go-github-mock in tests +type EndpointPattern string + +type MockBackendOption func(map[string]http.HandlerFunc) + +func parseEndpointPattern(p EndpointPattern) (string, string) { + parts := strings.SplitN(string(p), " ", 2) + if len(parts) != 2 { + return http.MethodGet, string(p) + } + return parts[0], parts[1] +} + +func WithRequestMatch(pattern EndpointPattern, response any) MockBackendOption { + return func(handlers map[string]http.HandlerFunc) { + method, path := parseEndpointPattern(pattern) + handlers[method+" "+path] = func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + switch v := response.(type) { + case string: + _, _ = w.Write([]byte(v)) + case []byte: + _, _ = w.Write(v) + default: + data, err := json.Marshal(v) + if err == nil { + _, _ = w.Write(data) + } + } + } + } +} + +func WithRequestMatchHandler(pattern EndpointPattern, handler http.HandlerFunc) MockBackendOption { + return func(handlers map[string]http.HandlerFunc) { + method, path := parseEndpointPattern(pattern) + handlers[method+" "+path] = handler + } +} + +func NewMockedHTTPClient(options ...MockBackendOption) *http.Client { + handlers := map[string]http.HandlerFunc{} + for _, opt := range options { + if opt != nil { + opt(handlers) + } + } + return MockHTTPClientWithHandlers(handlers) +} + +func MustMarshal(v any) []byte { + data, err := json.Marshal(v) + if err != nil { + panic(err) + } + return data +} + type multiHandlerTransport struct { handlers map[string]http.HandlerFunc } diff --git a/pkg/github/instructions_test.go b/pkg/github/instructions_test.go deleted file mode 100644 index b8ad2ba8c..000000000 --- a/pkg/github/instructions_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package github - -import ( - "os" - "strings" - "testing" -) - -func TestGenerateInstructions(t *testing.T) { - tests := []struct { - name string - enabledToolsets []string - expectedEmpty bool - }{ - { - name: "empty toolsets", - enabledToolsets: []string{}, - expectedEmpty: false, - }, - { - name: "only context toolset", - enabledToolsets: []string{"context"}, - expectedEmpty: false, - }, - { - name: "pull requests toolset", - enabledToolsets: []string{"pull_requests"}, - expectedEmpty: false, - }, - { - name: "issues toolset", - enabledToolsets: []string{"issues"}, - expectedEmpty: false, - }, - { - name: "discussions toolset", - enabledToolsets: []string{"discussions"}, - expectedEmpty: false, - }, - { - name: "multiple toolsets (context + pull_requests)", - enabledToolsets: []string{"context", "pull_requests"}, - expectedEmpty: false, - }, - { - name: "multiple toolsets (issues + pull_requests)", - enabledToolsets: []string{"issues", "pull_requests"}, - expectedEmpty: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := GenerateInstructions(tt.enabledToolsets) - - if tt.expectedEmpty { - if result != "" { - t.Errorf("Expected empty instructions but got: %s", result) - } - } else { - if result == "" { - t.Errorf("Expected non-empty instructions but got empty result") - } - } - }) - } -} - -func TestGenerateInstructionsWithDisableFlag(t *testing.T) { - tests := []struct { - name string - disableEnvValue string - enabledToolsets []string - expectedEmpty bool - }{ - { - name: "DISABLE_INSTRUCTIONS=true returns empty", - disableEnvValue: "true", - enabledToolsets: []string{"context", "issues", "pull_requests"}, - expectedEmpty: true, - }, - { - name: "DISABLE_INSTRUCTIONS=false returns normal instructions", - disableEnvValue: "false", - enabledToolsets: []string{"context"}, - expectedEmpty: false, - }, - { - name: "DISABLE_INSTRUCTIONS unset returns normal instructions", - disableEnvValue: "", - enabledToolsets: []string{"issues"}, - expectedEmpty: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Save original env value - originalValue := os.Getenv("DISABLE_INSTRUCTIONS") - defer func() { - if originalValue == "" { - os.Unsetenv("DISABLE_INSTRUCTIONS") - } else { - os.Setenv("DISABLE_INSTRUCTIONS", originalValue) - } - }() - - // Set test env value - if tt.disableEnvValue == "" { - os.Unsetenv("DISABLE_INSTRUCTIONS") - } else { - os.Setenv("DISABLE_INSTRUCTIONS", tt.disableEnvValue) - } - - result := GenerateInstructions(tt.enabledToolsets) - - if tt.expectedEmpty { - if result != "" { - t.Errorf("Expected empty instructions but got: %s", result) - } - } else { - if result == "" { - t.Errorf("Expected non-empty instructions but got empty result") - } - } - }) - } -} - -func TestGetToolsetInstructions(t *testing.T) { - tests := []struct { - toolset string - expectedEmpty bool - enabledToolsets []string - expectedToContain string - notExpectedToContain string - }{ - { - toolset: "pull_requests", - expectedEmpty: false, - enabledToolsets: []string{"pull_requests", "repos"}, - expectedToContain: "pull_request_template.md", - }, - { - toolset: "pull_requests", - expectedEmpty: false, - enabledToolsets: []string{"pull_requests"}, - notExpectedToContain: "pull_request_template.md", - }, - { - toolset: "issues", - expectedEmpty: false, - }, - { - toolset: "discussions", - expectedEmpty: false, - }, - { - toolset: "nonexistent", - expectedEmpty: true, - }, - } - - for _, tt := range tests { - t.Run(tt.toolset, func(t *testing.T) { - result := getToolsetInstructions(tt.toolset, tt.enabledToolsets) - if tt.expectedEmpty { - if result != "" { - t.Errorf("Expected empty result for toolset '%s', but got: %s", tt.toolset, result) - } - } else { - if result == "" { - t.Errorf("Expected non-empty result for toolset '%s', but got empty", tt.toolset) - } - } - - if tt.expectedToContain != "" && !strings.Contains(result, tt.expectedToContain) { - t.Errorf("Expected result to contain '%s' for toolset '%s', but it did not. Result: %s", tt.expectedToContain, tt.toolset, result) - } - - if tt.notExpectedToContain != "" && strings.Contains(result, tt.notExpectedToContain) { - t.Errorf("Did not expect result to contain '%s' for toolset '%s', but it did. Result: %s", tt.notExpectedToContain, tt.toolset, result) - } - }) - } -} diff --git a/pkg/github/issues.go b/pkg/github/issues.go index f06dc2d9d..62e1a0bac 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -14,6 +14,7 @@ import ( "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/sanitize" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" @@ -274,6 +275,7 @@ Options are: }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { @@ -565,6 +567,7 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner"}, }, }, + []scopes.Scope{scopes.ReadOrg}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -632,6 +635,7 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "issue_number", "body"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -736,6 +740,7 @@ Options are: Required: []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { @@ -963,6 +968,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues") return result, nil, err @@ -1052,6 +1058,7 @@ Options are: Required: []string{"method", "owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { @@ -1175,7 +1182,11 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo issue, resp, err := client.Issues.Create(ctx, owner, repo, issueRequest) if err != nil { - return utils.NewToolResultErrorFromErr("failed to create issue", err), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to create issue", + resp, + err, + ), nil } defer func() { _ = resp.Body.Close() }() @@ -1381,6 +1392,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1522,7 +1534,11 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { issueQuery := getIssueQueryType(hasLabels, hasSince) if err := client.Query(ctx, issueQuery, vars); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return ghErrors.NewGitHubGraphQLErrorResponse( + ctx, + "failed to list issues", + err, + ), nil, nil } // Extract and convert all issue nodes using the common interface @@ -1593,6 +1609,104 @@ func (d *mvpDescription) String() string { return sb.String() } +// linkedPullRequest represents a PR linked to an issue by Copilot. +type linkedPullRequest struct { + Number int + URL string + Title string + State string + CreatedAt time.Time +} + +// pollConfigKey is a context key for polling configuration. +type pollConfigKey struct{} + +// PollConfig configures the PR polling behavior. +type PollConfig struct { + MaxAttempts int + Delay time.Duration +} + +// ContextWithPollConfig returns a context with polling configuration. +// Use this in tests to reduce or disable polling. +func ContextWithPollConfig(ctx context.Context, config PollConfig) context.Context { + return context.WithValue(ctx, pollConfigKey{}, config) +} + +// getPollConfig returns the polling configuration from context, or defaults. +func getPollConfig(ctx context.Context) PollConfig { + if config, ok := ctx.Value(pollConfigKey{}).(PollConfig); ok { + return config + } + // Default: 9 attempts with 1s delay = 8s max wait + // Based on observed latency in remote server: p50 ~5s, p90 ~7s + return PollConfig{MaxAttempts: 9, Delay: 1 * time.Second} +} + +// findLinkedCopilotPR searches for a PR created by the copilot-swe-agent bot that references the given issue. +// It queries the issue's timeline for CrossReferencedEvent items from PRs authored by copilot-swe-agent. +// The createdAfter parameter filters to only return PRs created after the specified time. +func findLinkedCopilotPR(ctx context.Context, client *githubv4.Client, owner, repo string, issueNumber int, createdAfter time.Time) (*linkedPullRequest, error) { + // Query timeline items looking for CrossReferencedEvent from PRs by copilot-swe-agent + var query struct { + Repository struct { + Issue struct { + TimelineItems struct { + Nodes []struct { + TypeName string `graphql:"__typename"` + CrossReferencedEvent struct { + Source struct { + PullRequest struct { + Number int + URL string + Title string + State string + CreatedAt githubv4.DateTime + Author struct { + Login string + } + } `graphql:"... on PullRequest"` + } + } `graphql:"... on CrossReferencedEvent"` + } + } `graphql:"timelineItems(first: 20, itemTypes: [CROSS_REFERENCED_EVENT])"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]any{ + "owner": githubv4.String(owner), + "name": githubv4.String(repo), + "number": githubv4.Int(issueNumber), //nolint:gosec // Issue numbers are always small positive integers + } + + if err := client.Query(ctx, &query, variables); err != nil { + return nil, err + } + + // Look for a PR from copilot-swe-agent created after the assignment time + for _, node := range query.Repository.Issue.TimelineItems.Nodes { + if node.TypeName != "CrossReferencedEvent" { + continue + } + pr := node.CrossReferencedEvent.Source.PullRequest + if pr.Number > 0 && pr.Author.Login == "copilot-swe-agent" { + // Only return PRs created after the assignment time + if pr.CreatedAt.Time.After(createdAfter) { + return &linkedPullRequest{ + Number: pr.Number, + URL: pr.URL, + Title: pr.Title, + State: pr.State, + CreatedAt: pr.CreatedAt.Time, + }, nil + } + } + } + + return nil, nil +} + func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.ServerTool { description := mvpDescription{ summary: "Assign Copilot to a specific issue in a GitHub repository.", @@ -1626,19 +1740,30 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server Type: "string", Description: "Repository name", }, - "issueNumber": { + "issue_number": { Type: "number", Description: "Issue number", }, + "base_ref": { + Type: "string", + Description: "Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch", + }, + "custom_instructions": { + Type: "string", + Description: "Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description", + }, }, - Required: []string{"owner", "repo", "issueNumber"}, + Required: []string{"owner", "repo", "issue_number"}, }, }, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params struct { - Owner string - Repo string - IssueNumber int32 + Owner string `mapstructure:"owner"` + Repo string `mapstructure:"repo"` + IssueNumber int32 `mapstructure:"issue_number"` + BaseRef string `mapstructure:"base_ref"` + CustomInstructions string `mapstructure:"custom_instructions"` } if err := mapstructure.Decode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1683,7 +1808,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server var query suggestedActorsQuery err := client.Query(ctx, &query, variables) if err != nil { - return nil, nil, err + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get suggested actors", err), nil, nil } // Iterate all the returned nodes looking for the copilot bot, which is supposed to have the @@ -1707,10 +1832,10 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server return utils.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil, nil } - // Next let's get the GQL Node ID and current assignees for this issue because the only way to - // assign copilot is to use replaceActorsForAssignable which requires the full list. + // Next, get the issue ID and repository ID var getIssueQuery struct { Repository struct { + ID githubv4.ID Issue struct { ID githubv4.ID Assignees struct { @@ -1729,36 +1854,140 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server } if err := client.Query(ctx, &getIssueQuery, variables); err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to get issue ID: %v", err)), nil, nil - } - - // Finally, do the assignment. Just for reference, assigning copilot to an issue that it is already - // assigned to seems to have no impact (which is a good thing). - var assignCopilotMutation struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` // Not required but we need a selector or GQL errors - } `graphql:"replaceActorsForAssignable(input: $input)"` + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue ID", err), nil, nil } + // Build the assignee IDs list including copilot actorIDs := make([]githubv4.ID, len(getIssueQuery.Repository.Issue.Assignees.Nodes)+1) for i, node := range getIssueQuery.Repository.Issue.Assignees.Nodes { actorIDs[i] = node.ID } actorIDs[len(getIssueQuery.Repository.Issue.Assignees.Nodes)] = copilotAssignee.ID + // Prepare agent assignment input + emptyString := githubv4.String("") + agentAssignment := &AgentAssignmentInput{ + CustomAgent: &emptyString, + CustomInstructions: &emptyString, + TargetRepositoryID: getIssueQuery.Repository.ID, + } + + // Add base ref if provided + if params.BaseRef != "" { + baseRef := githubv4.String(params.BaseRef) + agentAssignment.BaseRef = &baseRef + } + + // Add custom instructions if provided + if params.CustomInstructions != "" { + customInstructions := githubv4.String(params.CustomInstructions) + agentAssignment.CustomInstructions = &customInstructions + } + + // Execute the updateIssue mutation with the GraphQL-Features header + // This header is required for the agent assignment API which is not GA yet + var updateIssueMutation struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + } + + // Add the GraphQL-Features header for the agent assignment API + // The header will be read by the HTTP transport if it's configured to do so + ctxWithFeatures := withGraphQLFeatures(ctx, "issues_copilot_assignment_api_support") + + // Capture the time before assignment to filter out older PRs during polling + assignmentTime := time.Now().UTC() + if err := client.Mutate( - ctx, - &assignCopilotMutation, - ReplaceActorsForAssignableInput{ - AssignableID: getIssueQuery.Repository.Issue.ID, - ActorIDs: actorIDs, + ctxWithFeatures, + &updateIssueMutation, + UpdateIssueInput{ + ID: getIssueQuery.Repository.Issue.ID, + AssigneeIDs: actorIDs, + AgentAssignment: agentAssignment, }, nil, ); err != nil { - return nil, nil, fmt.Errorf("failed to replace actors for assignable: %w", err) + return nil, nil, fmt.Errorf("failed to update issue with agent assignment: %w", err) } - return utils.NewToolResultText("successfully assigned copilot to issue"), nil, nil + // Poll for a linked PR created by Copilot after the assignment + pollConfig := getPollConfig(ctx) + + // Get progress token from request for sending progress notifications + progressToken := request.Params.GetProgressToken() + + // Send initial progress notification that assignment succeeded and polling is starting + if progressToken != nil && request.Session != nil && pollConfig.MaxAttempts > 0 { + _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: progressToken, + Progress: 0, + Total: float64(pollConfig.MaxAttempts), + Message: "Copilot assigned to issue, waiting for PR creation...", + }) + } + + var linkedPR *linkedPullRequest + for attempt := range pollConfig.MaxAttempts { + if attempt > 0 { + time.Sleep(pollConfig.Delay) + } + + // Send progress notification if progress token is available + if progressToken != nil && request.Session != nil { + _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: progressToken, + Progress: float64(attempt + 1), + Total: float64(pollConfig.MaxAttempts), + Message: fmt.Sprintf("Waiting for Copilot to create PR... (attempt %d/%d)", attempt+1, pollConfig.MaxAttempts), + }) + } + + pr, err := findLinkedCopilotPR(ctx, client, params.Owner, params.Repo, int(params.IssueNumber), assignmentTime) + if err != nil { + // Polling errors are non-fatal, continue to next attempt + continue + } + if pr != nil { + linkedPR = pr + break + } + } + + // Build the result + result := map[string]any{ + "message": "successfully assigned copilot to issue", + "issue_number": int(updateIssueMutation.UpdateIssue.Issue.Number), + "issue_url": string(updateIssueMutation.UpdateIssue.Issue.URL), + "owner": params.Owner, + "repo": params.Repo, + } + + // Add PR info if found during polling + if linkedPR != nil { + result["pull_request"] = map[string]any{ + "number": linkedPR.Number, + "url": linkedPR.URL, + "title": linkedPR.Title, + "state": linkedPR.State, + } + result["message"] = "successfully assigned copilot to issue - pull request created" + } else { + result["message"] = "successfully assigned copilot to issue - pull request pending" + result["note"] = "The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates." + } + + r, err := json.Marshal(result) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil + } + + return utils.NewToolResultText(string(r)), result, nil }) } @@ -1767,6 +1996,21 @@ type ReplaceActorsForAssignableInput struct { ActorIDs []githubv4.ID `json:"actorIds"` } +// AgentAssignmentInput represents the input for assigning an agent to an issue. +type AgentAssignmentInput struct { + BaseRef *githubv4.String `json:"baseRef,omitempty"` + CustomAgent *githubv4.String `json:"customAgent,omitempty"` + CustomInstructions *githubv4.String `json:"customInstructions,omitempty"` + TargetRepositoryID githubv4.ID `json:"targetRepositoryId"` +} + +// UpdateIssueInput represents the input for updating an issue with agent assignment. +type UpdateIssueInput struct { + ID githubv4.ID `json:"id"` + AssigneeIDs []githubv4.ID `json:"assigneeIds"` + AgentAssignment *AgentAssignmentInput `json:"agentAssignment,omitempty"` +} + // parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. // Returns the parsed time or an error if parsing fails. // Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15" @@ -1852,3 +2096,19 @@ func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) inventory.Ser }, ) } + +// graphQLFeaturesKey is a context key for GraphQL feature flags +type graphQLFeaturesKey struct{} + +// withGraphQLFeatures adds GraphQL feature flags to the context +func withGraphQLFeatures(ctx context.Context, features ...string) context.Context { + return context.WithValue(ctx, graphQLFeaturesKey{}, features) +} + +// GetGraphQLFeatures retrieves GraphQL feature flags from the context +func GetGraphQLFeatures(ctx context.Context) []string { + if features, ok := ctx.Value(graphQLFeaturesKey{}).([]string); ok { + return features + } + return nil +} diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index b810cede3..a338efcba 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -17,7 +17,6 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -181,12 +180,9 @@ func Test_GetIssue(t *testing.T) { }{ { name: "successful issue retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesByOwnerByRepoByIssueNumber, - mockIssue, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "get", "owner": "owner2", @@ -197,12 +193,9 @@ func Test_GetIssue(t *testing.T) { }, { name: "issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "get", "owner": "owner", @@ -214,12 +207,9 @@ func Test_GetIssue(t *testing.T) { }, { name: "lockdown enabled - private repository", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesByOwnerByRepoByIssueNumber, - mockIssue2, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue2), + }), gqlHTTPClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -261,12 +251,9 @@ func Test_GetIssue(t *testing.T) { }, { name: "lockdown enabled - user lacks push access", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesByOwnerByRepoByIssueNumber, - mockIssue, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }), gqlHTTPClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -406,12 +393,9 @@ func Test_AddIssueComment(t *testing.T) { }{ { name: "successful comment creation", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusCreated, mockComment), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockComment), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -423,15 +407,12 @@ func Test_AddIssueComment(t *testing.T) { }, { name: "comment creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesCommentsByOwnerByRepoByIssueNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) + }), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -546,23 +527,20 @@ func Test_SearchIssues(t *testing.T) { }{ { name: "successful issues search with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue repo:owner/repo is:open", - "sort": "created", - "order": "desc", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue repo:owner/repo is:open", + "sort": "created", + "order": "desc", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:owner/repo is:open", "sort": "created", @@ -575,23 +553,20 @@ func Test_SearchIssues(t *testing.T) { }, { name: "issues search with owner and repo parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "repo:test-owner/test-repo is:issue is:open", - "sort": "created", - "order": "asc", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "repo:test-owner/test-repo is:issue is:open", + "sort": "created", + "order": "asc", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "is:open", "owner": "test-owner", @@ -604,21 +579,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "issues search with only owner parameter (should ignore it)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue bug", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue bug", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "bug", "owner": "test-owner", @@ -628,21 +600,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "issues search with only repo parameter (should ignore it)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue feature", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue feature", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "feature", "repo": "test-repo", @@ -652,12 +621,9 @@ func Test_SearchIssues(t *testing.T) { }, { name: "issues search with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetSearchIssues, - mockSearchResult, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult), + }), requestArgs: map[string]interface{}{ "query": "is:issue repo:owner/repo is:open", }, @@ -666,21 +632,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "query with existing is:issue filter - no duplication", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", }, @@ -689,21 +652,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "query with existing repo: filter and conflicting owner/repo params - uses query filter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue repo:github/github-mcp-server critical", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue repo:github/github-mcp-server critical", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:github/github-mcp-server critical", "owner": "different-owner", @@ -714,21 +674,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "query with both is: and repo: filters already present", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue repo:octocat/Hello-World bug", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue repo:octocat/Hello-World bug", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "is:issue repo:octocat/Hello-World bug", }, @@ -737,21 +694,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "complex query with multiple OR operators and existing filters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", }, @@ -760,15 +714,12 @@ func Test_SearchIssues(t *testing.T) { }, { name: "search issues fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, @@ -868,21 +819,18 @@ func Test_CreateIssue(t *testing.T) { }{ { name: "successful issue creation with all fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesByOwnerByRepo, - expectRequestBody(t, map[string]any{ - "title": "Test Issue", - "body": "This is a test issue", - "labels": []any{"bug", "help wanted"}, - "assignees": []any{"user1", "user2"}, - "milestone": float64(5), - "type": "Bug", - }).andThen( - mockResponse(t, http.StatusCreated, mockIssue), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{ + "title": "Test Issue", + "body": "This is a test issue", + "labels": []any{"bug", "help wanted"}, + "assignees": []any{"user1", "user2"}, + "milestone": float64(5), + "type": "Bug", + }).andThen( + mockResponse(t, http.StatusCreated, mockIssue), ), - ), + }), requestArgs: map[string]interface{}{ "method": "create", "owner": "owner", @@ -899,17 +847,14 @@ func Test_CreateIssue(t *testing.T) { }, { name: "successful issue creation with minimal fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesByOwnerByRepo, - mockResponse(t, http.StatusCreated, &github.Issue{ - Number: github.Ptr(124), - Title: github.Ptr("Minimal Issue"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), - State: github.Ptr("open"), - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: mockResponse(t, http.StatusCreated, &github.Issue{ + Number: github.Ptr(124), + Title: github.Ptr("Minimal Issue"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), + State: github.Ptr("open"), + }), + }), requestArgs: map[string]interface{}{ "method": "create", "owner": "owner", @@ -927,15 +872,12 @@ func Test_CreateIssue(t *testing.T) { }, { name: "issue creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Validation failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Validation failed"}`)) + }), + }), requestArgs: map[string]interface{}{ "method": "create", "owner": "owner", @@ -1427,17 +1369,14 @@ func Test_UpdateIssue(t *testing.T) { }{ { name: "partial update of non-state fields only", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - expectRequestBody(t, map[string]interface{}{ - "title": "Updated Title", - "body": "Updated Description", - }).andThen( - mockResponse(t, http.StatusOK, mockUpdatedIssue), - ), - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]interface{}{ + "title": "Updated Title", + "body": "Updated Description", + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedIssue), + ), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "method": "update", @@ -1452,15 +1391,12 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "issue not found when updating non-state fields only", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "method": "update", @@ -1474,12 +1410,9 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "close issue as duplicate", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - mockBaseIssue, - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -1534,12 +1467,9 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "reopen issue", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - mockBaseIssue, - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -1586,12 +1516,9 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "main issue not found when trying to close it", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - mockBaseIssue, - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -1622,12 +1549,9 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "duplicate issue not found when closing as duplicate", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - mockBaseIssue, - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -1663,31 +1587,28 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "close as duplicate with combined non-state updates", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - expectRequestBody(t, map[string]interface{}{ - "title": "Updated Title", - "body": "Updated Description", - "labels": []any{"bug", "priority"}, - "assignees": []any{"assignee1", "assignee2"}, - "milestone": float64(5), - "type": "Bug", - }).andThen( - mockResponse(t, http.StatusOK, &github.Issue{ - Number: github.Ptr(123), - Title: github.Ptr("Updated Title"), - Body: github.Ptr("Updated Description"), - Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, - Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, - Milestone: &github.Milestone{Number: github.Ptr(5)}, - Type: &github.IssueType{Name: github.Ptr("Bug")}, - State: github.Ptr("open"), // Still open after REST update - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), - }), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]interface{}{ + "title": "Updated Title", + "body": "Updated Description", + "labels": []any{"bug", "priority"}, + "assignees": []any{"assignee1", "assignee2"}, + "milestone": float64(5), + "type": "Bug", + }).andThen( + mockResponse(t, http.StatusOK, &github.Issue{ + Number: github.Ptr(123), + Title: github.Ptr("Updated Title"), + Body: github.Ptr("Updated Description"), + Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, + Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, + Milestone: &github.Milestone{Number: github.Ptr(5)}, + Type: &github.IssueType{Name: github.Ptr("Bug")}, + State: github.Ptr("open"), // Still open after REST update + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + }), ), - ), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -1748,7 +1669,7 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "duplicate_of without duplicate state_reason should fail", - mockedRESTClient: mock.NewMockedHTTPClient(), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), mockedGQLClient: githubv4mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "method": "update", @@ -1910,12 +1831,9 @@ func Test_GetIssueComments(t *testing.T) { }{ { name: "successful comments retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, - mockComments, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockComments), + }), requestArgs: map[string]interface{}{ "method": "get_comments", "owner": "owner", @@ -1927,17 +1845,14 @@ func Test_GetIssueComments(t *testing.T) { }, { name: "successful comments retrieval with pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, - expectQueryParams(t, map[string]string{ - "page": "2", - "per_page": "10", - }).andThen( - mockResponse(t, http.StatusOK, mockComments), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesCommentsByOwnerByRepoByIssueNumber: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockComments), ), - ), + }), requestArgs: map[string]interface{}{ "method": "get_comments", "owner": "owner", @@ -1951,12 +1866,9 @@ func Test_GetIssueComments(t *testing.T) { }, { name: "issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "get_comments", "owner": "owner", @@ -1968,23 +1880,20 @@ func Test_GetIssueComments(t *testing.T) { }, { name: "lockdown enabled filters comments without push access", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, - []*github.IssueComment{ - { - ID: github.Ptr(int64(789)), - Body: github.Ptr("Maintainer comment"), - User: &github.User{Login: github.Ptr("maintainer")}, - }, - { - ID: github.Ptr(int64(790)), - Body: github.Ptr("External user comment"), - User: &github.User{Login: github.Ptr("testuser")}, - }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, []*github.IssueComment{ + { + ID: github.Ptr(int64(789)), + Body: github.Ptr("Maintainer comment"), + User: &github.User{Login: github.Ptr("maintainer")}, }, - ), - ), + { + ID: github.Ptr(int64(790)), + Body: github.Ptr("External user comment"), + User: &github.User{Login: github.Ptr("testuser")}, + }, + }), + }), gqlHTTPClient: newRepoAccessHTTPClient(), requestArgs: map[string]interface{}{ "method": "get_comments", @@ -2175,8 +2084,16 @@ func TestAssignCopilotToIssue(t *testing.T) { assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issueNumber") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issueNumber"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "base_ref") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "custom_instructions") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issue_number"}) + + // Helper function to create pointer to githubv4.String + ptrGitHubv4String := func(s string) *githubv4.String { + v := githubv4.String(s) + return &v + } var pageOfFakeBots = func(n int) []struct{} { // We don't _really_ need real bots here, just objects that count as entries for the page @@ -2197,9 +2114,9 @@ func TestAssignCopilotToIssue(t *testing.T) { { name: "successful assignment when there are no existing assignees", requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issueNumber": float64(123), + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( @@ -2242,6 +2159,7 @@ func TestAssignCopilotToIssue(t *testing.T) { githubv4mock.NewQueryMatcher( struct { Repository struct { + ID githubv4.ID Issue struct { ID githubv4.ID Assignees struct { @@ -2259,6 +2177,7 @@ func TestAssignCopilotToIssue(t *testing.T) { }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), "issue": map[string]any{ "id": githubv4.ID("test-issue-id"), "assignees": map[string]any{ @@ -2270,25 +2189,43 @@ func TestAssignCopilotToIssue(t *testing.T) { ), githubv4mock.NewMutationMatcher( struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` - } `graphql:"replaceActorsForAssignable(input: $input)"` + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` }{}, - ReplaceActorsForAssignableInput{ - AssignableID: githubv4.ID("test-issue-id"), - ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, }, nil, - githubv4mock.DataResponse(map[string]any{}), + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), ), ), }, { name: "successful assignment when there are existing assignees", requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issueNumber": float64(123), + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( @@ -2331,6 +2268,7 @@ func TestAssignCopilotToIssue(t *testing.T) { githubv4mock.NewQueryMatcher( struct { Repository struct { + ID githubv4.ID Issue struct { ID githubv4.ID Assignees struct { @@ -2348,6 +2286,7 @@ func TestAssignCopilotToIssue(t *testing.T) { }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), "issue": map[string]any{ "id": githubv4.ID("test-issue-id"), "assignees": map[string]any{ @@ -2366,29 +2305,47 @@ func TestAssignCopilotToIssue(t *testing.T) { ), githubv4mock.NewMutationMatcher( struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` - } `graphql:"replaceActorsForAssignable(input: $input)"` + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` }{}, - ReplaceActorsForAssignableInput{ - AssignableID: githubv4.ID("test-issue-id"), - ActorIDs: []githubv4.ID{ + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{ githubv4.ID("existing-assignee-id"), githubv4.ID("existing-assignee-id-2"), githubv4.ID("copilot-swe-agent-id"), }, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, }, nil, - githubv4mock.DataResponse(map[string]any{}), + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), ), ), }, { name: "copilot bot not on first page of suggested actors", requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issueNumber": float64(123), + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), }, mockedClient: githubv4mock.NewMockedHTTPClient( // First page of suggested actors @@ -2468,6 +2425,7 @@ func TestAssignCopilotToIssue(t *testing.T) { githubv4mock.NewQueryMatcher( struct { Repository struct { + ID githubv4.ID Issue struct { ID githubv4.ID Assignees struct { @@ -2485,6 +2443,7 @@ func TestAssignCopilotToIssue(t *testing.T) { }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), "issue": map[string]any{ "id": githubv4.ID("test-issue-id"), "assignees": map[string]any{ @@ -2496,25 +2455,43 @@ func TestAssignCopilotToIssue(t *testing.T) { ), githubv4mock.NewMutationMatcher( struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` - } `graphql:"replaceActorsForAssignable(input: $input)"` + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` }{}, - ReplaceActorsForAssignableInput{ - AssignableID: githubv4.ID("test-issue-id"), - ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, }, nil, - githubv4mock.DataResponse(map[string]any{}), + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), ), ), }, { name: "copilot not a suggested actor", requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issueNumber": float64(123), + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( @@ -2552,6 +2529,226 @@ func TestAssignCopilotToIssue(t *testing.T) { expectToolError: true, expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.", }, + { + name: "successful assignment with base_ref specified", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "base_ref": "feature-branch", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: ptrGitHubv4String("feature-branch"), + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ), + ), + }, + { + name: "successful assignment with custom_instructions specified", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "custom_instructions": "Please ensure all code follows PEP 8 style guidelines and includes comprehensive docstrings", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String("Please ensure all code follows PEP 8 style guidelines and includes comprehensive docstrings"), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ), + ), + }, } for _, tc := range tests { @@ -2568,8 +2765,12 @@ func TestAssignCopilotToIssue(t *testing.T) { // Create call request request := createMCPRequest(tc.requestArgs) + // Disable polling in tests to avoid timeouts + ctx := ContextWithPollConfig(context.Background(), PollConfig{MaxAttempts: 0}) + ctx = ContextWithDeps(ctx, deps) + // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) + result, err := handler(ctx, &request) require.NoError(t, err) textContent := getTextResult(t, result) @@ -2581,7 +2782,16 @@ func TestAssignCopilotToIssue(t *testing.T) { } require.False(t, result.IsError, fmt.Sprintf("expected there to be no tool error, text was %s", textContent.Text)) - require.Equal(t, textContent.Text, "successfully assigned copilot to issue") + + // Verify the JSON response contains expected fields + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err, "response should be valid JSON") + assert.Equal(t, float64(123), response["issue_number"]) + assert.Equal(t, "https://github.com/owner/repo/issues/123", response["issue_url"]) + assert.Equal(t, "owner", response["owner"]) + assert.Equal(t, "repo", response["repo"]) + assert.Contains(t, response["message"], "successfully assigned copilot to issue") }) } } @@ -2631,12 +2841,9 @@ func Test_AddSubIssue(t *testing.T) { }{ { name: "successful sub-issue addition with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusCreated, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2650,12 +2857,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "successful sub-issue addition with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusCreated, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2668,12 +2872,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "successful sub-issue addition with replace_parent false", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusCreated, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2687,12 +2888,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "parent issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2705,12 +2903,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "sub-issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2723,12 +2918,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "validation failed - sub-issue cannot be parent of itself", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2741,12 +2933,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "insufficient permissions", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2759,9 +2948,7 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "add", "repo": "repo", @@ -2773,9 +2960,7 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "missing required parameter sub_issue_id", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2895,12 +3080,9 @@ func Test_GetSubIssues(t *testing.T) { }{ { name: "successful sub-issues listing with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockSubIssues, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockSubIssues), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -2912,17 +3094,14 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "successful sub-issues listing with pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - expectQueryParams(t, map[string]string{ - "page": "2", - "per_page": "10", - }).andThen( - mockResponse(t, http.StatusOK, mockSubIssues), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockSubIssues), ), - ), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -2936,12 +3115,9 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "successful sub-issues listing with empty result", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - []*github.Issue{}, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, []*github.Issue{}), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -2953,12 +3129,9 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "parent issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -2970,12 +3143,9 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "repository not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "nonexistent", @@ -2987,12 +3157,9 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "sub-issues feature gone/deprecated", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -3004,9 +3171,7 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "repo": "repo", @@ -3017,9 +3182,7 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "missing required parameter issue_number", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -3135,12 +3298,9 @@ func Test_RemoveSubIssue(t *testing.T) { }{ { name: "successful sub-issue removal", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusOK, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3153,12 +3313,9 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "parent issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3171,12 +3328,9 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "sub-issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3189,12 +3343,9 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "bad request - invalid sub_issue_id", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3207,12 +3358,9 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "repository not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "nonexistent", @@ -3225,12 +3373,9 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "insufficient permissions", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3243,9 +3388,7 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "remove", "repo": "repo", @@ -3257,9 +3400,7 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "missing required parameter sub_issue_id", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3365,12 +3506,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }{ { name: "successful reprioritization with after_id", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusOK, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3384,12 +3522,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "successful reprioritization with before_id", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusOK, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3403,9 +3538,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "validation error - neither after_id nor before_id specified", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3418,9 +3551,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "validation error - both after_id and before_id specified", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3435,12 +3566,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "parent issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3454,12 +3582,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "sub-issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3473,12 +3598,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "validation failed - positioning sub-issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3492,12 +3614,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "insufficient permissions", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3511,12 +3630,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "service unavailable", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3530,9 +3646,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "reprioritize", "repo": "repo", @@ -3545,9 +3659,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "missing required parameter sub_issue_id", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3645,15 +3757,9 @@ func Test_ListIssueTypes(t *testing.T) { }{ { name: "successful issue types retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/orgs/testorg/issue-types", - Method: "GET", - }, - mockResponse(t, http.StatusOK, mockIssueTypes), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/testorg/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes), + }), requestArgs: map[string]interface{}{ "owner": "testorg", }, @@ -3662,15 +3768,9 @@ func Test_ListIssueTypes(t *testing.T) { }, { name: "organization not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/orgs/nonexistent/issue-types", - Method: "GET", - }, - mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/nonexistent/issue-types": mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`), + }), requestArgs: map[string]interface{}{ "owner": "nonexistent", }, @@ -3679,15 +3779,9 @@ func Test_ListIssueTypes(t *testing.T) { }, { name: "missing owner parameter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/orgs/testorg/issue-types", - Method: "GET", - }, - mockResponse(t, http.StatusOK, mockIssueTypes), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/testorg/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes), + }), requestArgs: map[string]interface{}{}, expectError: false, // This should be handled by parameter validation, error returned in result expectedErrMsg: "missing required parameter: owner", diff --git a/pkg/github/labels.go b/pkg/github/labels.go index 2811cf66e..0dbb622d9 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -8,6 +8,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/jsonschema-go/jsonschema" @@ -45,6 +46,7 @@ func GetLabel(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "name"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -142,6 +144,7 @@ func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -253,6 +256,7 @@ func LabelWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"method", "owner", "repo", "name"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Get and validate required parameters method, err := RequiredParam[string](args, "method") diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index b055efb38..c6a0ea849 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -131,6 +131,7 @@ type MinimalProject struct { Number *int `json:"number,omitempty"` ShortDescription *string `json:"short_description,omitempty"` DeletedBy *MinimalUser `json:"deleted_by,omitempty"` + OwnerType string `json:"owner_type,omitempty"` } // Helper functions diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index 1e2011fa3..1d695beb3 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -11,6 +11,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -62,6 +63,7 @@ func ListNotifications(t translations.TranslationHelperFunc) inventory.ServerToo }, }), }, + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -187,6 +189,7 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT Required: []string{"threadID", "state"}, }, }, + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -228,7 +231,7 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT } defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusResetContent && resp.StatusCode != http.StatusOK { + if resp.StatusCode != http.StatusResetContent && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil @@ -270,6 +273,7 @@ func MarkAllNotificationsRead(t translations.TranslationHelperFunc) inventory.Se }, }, }, + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -354,6 +358,7 @@ func GetNotificationDetails(t translations.TranslationHelperFunc) inventory.Serv Required: []string{"notificationID"}, }, }, + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -427,6 +432,7 @@ func ManageNotificationSubscription(t translations.TranslationHelperFunc) invent Required: []string{"notificationID", "action"}, }, }, + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -526,6 +532,7 @@ func ManageRepositoryNotificationSubscription(t translations.TranslationHelperFu Required: []string{"owner", "repo", "action"}, }, }, + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go index 936a70df4..d2124ae3d 100644 --- a/pkg/github/notifications_test.go +++ b/pkg/github/notifications_test.go @@ -472,7 +472,19 @@ func Test_DismissNotification(t *testing.T) { expectRead: true, }, { - name: "mark as done", + name: "mark as done with 204 response", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteNotificationsThreadsByThreadID: mockResponse(t, http.StatusNoContent, nil), + }), + requestArgs: map[string]interface{}{ + "threadID": "123", + "state": "done", + }, + expectError: false, + expectDone: true, + }, + { + name: "mark as done with 200 response", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ DeleteNotificationsThreadsByThreadID: mockResponse(t, http.StatusOK, nil), }), diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 0536bed99..4fed6364f 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -10,11 +10,13 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" ) const ( @@ -25,8 +27,25 @@ const ( MaxProjectsPerPage = 50 ) +// FeatureFlagHoldbackConsolidatedProjects is the feature flag that, when enabled, reverts to +// individual project tools instead of the consolidated project tools. +const FeatureFlagHoldbackConsolidatedProjects = "mcp_holdback_consolidated_projects" + +// Method constants for consolidated project tools +const ( + projectsMethodListProjects = "list_projects" + projectsMethodListProjectFields = "list_project_fields" + projectsMethodListProjectItems = "list_project_items" + projectsMethodGetProject = "get_project" + projectsMethodGetProjectField = "get_project_field" + projectsMethodGetProjectItem = "get_project_item" + projectsMethodAddProjectItem = "add_project_item" + projectsMethodUpdateProjectItem = "update_project_item" + projectsMethodDeleteProjectItem = "delete_project_item" +) + func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "list_projects", @@ -67,6 +86,7 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner_type", "owner"}, }, }, + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -140,10 +160,12 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool } func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project", @@ -172,6 +194,7 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"project_number", "owner_type", "owner"}, }, }, + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { projectNumber, err := RequiredInt(args, "project_number") @@ -228,10 +251,12 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool } func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "list_project_fields", @@ -272,6 +297,7 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner_type", "owner", "project_number"}, }, }, + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -334,10 +360,12 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool } func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project_field", @@ -370,6 +398,7 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner_type", "owner", "project_number", "field_id"}, }, }, + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -426,10 +455,12 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool } func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "list_project_items", @@ -481,6 +512,7 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner_type", "owner", "project_number"}, }, }, + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -562,10 +594,12 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool } func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project_item", @@ -605,6 +639,7 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner_type", "owner", "project_number", "item_id"}, }, }, + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -668,10 +703,12 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool } func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "add_project_item", @@ -709,6 +746,7 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner_type", "owner", "project_number", "item_type", "item_id"}, }, }, + []scopes.Scope{scopes.Project}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -779,10 +817,12 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool } func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "update_project_item", @@ -819,6 +859,7 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner_type", "owner", "project_number", "item_id", "updated_field"}, }, }, + []scopes.Scope{scopes.Project}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -891,10 +932,12 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool } func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "delete_project_item", @@ -928,6 +971,7 @@ func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner_type", "owner", "project_number", "item_id"}, }, }, + []scopes.Scope{scopes.Project}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -977,6 +1021,935 @@ func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultText("project item successfully deleted"), nil, nil }, ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool +} + +// ProjectsList returns the tool and handler for listing GitHub Projects resources. +func ProjectsList(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "projects_list", + Description: t("TOOL_PROJECTS_LIST_DESCRIPTION", + `Tools for listing GitHub Projects resources. +Use this tool to list projects for a user or organization, or list project fields and items for a specific project. +`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PROJECTS_LIST_USER_TITLE", "List GitHub Projects resources"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The action to perform", + Enum: []any{ + projectsMethodListProjects, + projectsMethodListProjectFields, + projectsMethodListProjectItems, + }, + }, + "owner_type": { + Type: "string", + Description: "Owner type (user or org). If not provided, will automatically try both.", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + Description: "The owner (user or organization login). The name is not case sensitive.", + }, + "project_number": { + Type: "number", + Description: "The project's number. Required for 'list_project_fields' and 'list_project_items' methods.", + }, + "query": { + Type: "string", + Description: `Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax.`, + }, + "fields": { + Type: "array", + Description: "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "per_page": { + Type: "number", + Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), + }, + "after": { + Type: "string", + Description: "Forward pagination cursor from previous pageInfo.nextCursor.", + }, + "before": { + Type: "string", + Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", + }, + }, + Required: []string{"method", "owner"}, + }, + }, + []scopes.Scope{scopes.ReadProject}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := OptionalParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case projectsMethodListProjects: + return listProjects(ctx, client, args, owner, ownerType) + case projectsMethodListProjectFields: + // Detect owner type if not provided and project_number is available + if ownerType == "" { + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + ownerType, err = detectOwnerType(ctx, client, owner, projectNumber) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } + return listProjectFields(ctx, client, args, owner, ownerType) + case projectsMethodListProjectItems: + // Detect owner type if not provided and project_number is available + if ownerType == "" { + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + ownerType, err = detectOwnerType(ctx, client, owner, projectNumber) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } + return listProjectItems(ctx, client, args, owner, ownerType) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }, + ) + tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects + return tool +} + +// ProjectsGet returns the tool and handler for getting GitHub Projects resources. +func ProjectsGet(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "projects_get", + Description: t("TOOL_PROJECTS_GET_DESCRIPTION", `Get details about specific GitHub Projects resources. +Use this tool to get details about individual projects, project fields, and project items by their unique IDs. +`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PROJECTS_GET_USER_TITLE", "Get details of GitHub Projects resources"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The method to execute", + Enum: []any{ + projectsMethodGetProject, + projectsMethodGetProjectField, + projectsMethodGetProjectItem, + }, + }, + "owner_type": { + Type: "string", + Description: "Owner type (user or org). If not provided, will be automatically detected.", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + Description: "The owner (user or organization login). The name is not case sensitive.", + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "field_id": { + Type: "number", + Description: "The field's ID. Required for 'get_project_field' method.", + }, + "item_id": { + Type: "number", + Description: "The item's ID. Required for 'get_project_item' method.", + }, + "fields": { + Type: "array", + Description: "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included. Only used for 'get_project_item' method.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + }, + Required: []string{"method", "owner", "project_number"}, + }, + }, + []scopes.Scope{scopes.ReadProject}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := OptionalParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Detect owner type if not provided + if ownerType == "" { + ownerType, err = detectOwnerType(ctx, client, owner, projectNumber) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } + + switch method { + case projectsMethodGetProject: + return getProject(ctx, client, owner, ownerType, projectNumber) + case projectsMethodGetProjectField: + fieldID, err := RequiredBigInt(args, "field_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID) + case projectsMethodGetProjectItem: + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + fields, err := OptionalBigIntArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }, + ) + tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects + return tool +} + +// ProjectsWrite returns the tool and handler for modifying GitHub Projects resources. +func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "projects_write", + Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Add, update, or delete project items in a GitHub Project."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Modify GitHub Project items"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The method to execute", + Enum: []any{ + projectsMethodAddProjectItem, + projectsMethodUpdateProjectItem, + projectsMethodDeleteProjectItem, + }, + }, + "owner_type": { + Type: "string", + Description: "Owner type (user or org). If not provided, will be automatically detected.", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + Description: "The project owner (user or organization login). The name is not case sensitive.", + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "item_id": { + Type: "number", + Description: "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods.", + }, + "item_type": { + Type: "string", + Description: "The item's type, either issue or pull_request. Required for 'add_project_item' method.", + Enum: []any{"issue", "pull_request"}, + }, + "item_owner": { + Type: "string", + Description: "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method.", + }, + "item_repo": { + Type: "string", + Description: "The name of the repository containing the issue or pull request. Required for 'add_project_item' method.", + }, + "issue_number": { + Type: "number", + Description: "The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number.", + }, + "pull_request_number": { + Type: "number", + Description: "The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number.", + }, + "updated_field": { + Type: "object", + Description: "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.", + }, + }, + Required: []string{"method", "owner", "project_number"}, + }, + }, + []scopes.Scope{scopes.Project}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := OptionalParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Detect owner type if not provided + if ownerType == "" { + ownerType, err = detectOwnerType(ctx, client, owner, projectNumber) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } + + switch method { + case projectsMethodAddProjectItem: + itemType, err := RequiredParam[string](args, "item_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + itemOwner, err := RequiredParam[string](args, "item_owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + itemRepo, err := RequiredParam[string](args, "item_repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var itemNumber int + switch itemType { + case "issue": + itemNumber, err = RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError("issue_number is required when item_type is 'issue'"), nil, nil + } + case "pull_request": + itemNumber, err = RequiredInt(args, "pull_request_number") + if err != nil { + return utils.NewToolResultError("pull_request_number is required when item_type is 'pull_request'"), nil, nil + } + default: + return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil + } + + return addProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, itemOwner, itemRepo, itemNumber, itemType) + case projectsMethodUpdateProjectItem: + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + rawUpdatedField, exists := args["updated_field"] + if !exists { + return utils.NewToolResultError("missing required parameter: updated_field"), nil, nil + } + fieldValue, ok := rawUpdatedField.(map[string]any) + if !ok || fieldValue == nil { + return utils.NewToolResultError("updated_field must be an object"), nil, nil + } + return updateProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fieldValue) + case projectsMethodDeleteProjectItem: + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return deleteProjectItem(ctx, client, owner, ownerType, projectNumber, itemID) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }, + ) + tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects + return tool +} + +// Helper functions for consolidated projects tools + +func listProjects(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { + queryStr, err := OptionalParam[string](args, "query") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := extractPaginationOptionsFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var projects []*github.ProjectV2 + var queryPtr *string + + if queryStr != "" { + queryPtr = &queryStr + } + + minimalProjects := []MinimalProject{} + opts := &github.ListProjectsOptions{ + ListProjectsPaginationOptions: pagination, + Query: queryPtr, + } + + // If owner_type not provided, fetch from both user and org + switch ownerType { + case "": + return listProjectsFromBothOwnerTypes(ctx, client, owner, opts) + case "org": + projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list projects", + resp, + err, + ), nil, nil + } + default: + projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list projects", + resp, + err, + ), nil, nil + } + } + + // For specified owner_type, process normally + if ownerType != "" { + defer func() { _ = resp.Body.Close() }() + + for _, project := range projects { + mp := convertToMinimalProject(project) + mp.OwnerType = ownerType + minimalProjects = append(minimalProjects, *mp) + } + + response := map[string]any{ + "projects": minimalProjects, + "pageInfo": buildPageInfo(resp), + } + + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil + } + + return nil, nil, fmt.Errorf("unexpected state in listProjects") +} + +// listProjectsFromBothOwnerTypes fetches projects from both user and org endpoints +// when owner_type is not specified, combining the results with owner_type labels. +func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, owner string, opts *github.ListProjectsOptions) (*mcp.CallToolResult, any, error) { + var minimalProjects []MinimalProject + var resp *github.Response + + // Fetch user projects + userProjects, userResp, userErr := client.Projects.ListUserProjects(ctx, owner, opts) + if userErr == nil && userResp.StatusCode == http.StatusOK { + for _, project := range userProjects { + mp := convertToMinimalProject(project) + mp.OwnerType = "user" + minimalProjects = append(minimalProjects, *mp) + } + _ = userResp.Body.Close() + } + + // Fetch org projects + orgProjects, orgResp, orgErr := client.Projects.ListOrganizationProjects(ctx, owner, opts) + if orgErr == nil && orgResp.StatusCode == http.StatusOK { + for _, project := range orgProjects { + mp := convertToMinimalProject(project) + mp.OwnerType = "org" + minimalProjects = append(minimalProjects, *mp) + } + resp = orgResp // Use org response for pagination info + } else if userResp != nil { + resp = userResp // Fallback to user response + } + + // If both failed, return error + if (userErr != nil || userResp == nil || userResp.StatusCode != http.StatusOK) && + (orgErr != nil || orgResp == nil || orgResp.StatusCode != http.StatusOK) { + return utils.NewToolResultError(fmt.Sprintf("failed to list projects for owner '%s': not found as user or organization", owner)), nil, nil + } + + response := map[string]any{ + "projects": minimalProjects, + "note": "Results include both user and org projects. Each project includes 'owner_type' field. Pagination is limited when owner_type is not specified - specify 'owner_type' for full pagination support.", + } + if resp != nil { + response["pageInfo"] = buildPageInfo(resp) + defer func() { _ = resp.Body.Close() }() + } + + 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 +} + +func listProjectFields(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := extractPaginationOptionsFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var projectFields []*github.ProjectV2Field + + opts := &github.ListProjectsOptions{ + ListProjectsPaginationOptions: pagination, + } + + if ownerType == "org" { + projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts) + } else { + projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list project fields", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + response := map[string]any{ + "fields": projectFields, + "pageInfo": buildPageInfo(resp), + } + + 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 +} + +func listProjectItems(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + queryStr, err := OptionalParam[string](args, "query") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + fields, err := OptionalBigIntArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := extractPaginationOptionsFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var projectItems []*github.ProjectV2Item + var queryPtr *string + + if queryStr != "" { + queryPtr = &queryStr + } + + opts := &github.ListProjectItemsOptions{ + Fields: fields, + ListProjectsOptions: github.ListProjectsOptions{ + ListProjectsPaginationOptions: pagination, + Query: queryPtr, + }, + } + + if ownerType == "org" { + projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts) + } else { + projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectListFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + response := map[string]any{ + "items": projectItems, + "pageInfo": buildPageInfo(resp), + } + + 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 +} + +func getProject(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var project *github.ProjectV2 + var err error + + if ownerType == "org" { + project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) + } else { + project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project", + 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 project", resp, body), nil, nil + } + + minimalProject := convertToMinimalProject(project) + r, err := json.Marshal(minimalProject) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getProjectField(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, fieldID int64) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var projectField *github.ProjectV2Field + var err error + + if ownerType == "org" { + projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID) + } else { + projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project field", + 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 project field", resp, body), nil, nil + } + r, err := json.Marshal(projectField) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fields []int64) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var projectItem *github.ProjectV2Item + var opts *github.GetProjectItemOptions + var err error + + if len(fields) > 0 { + opts = &github.GetProjectItemOptions{ + Fields: fields, + } + } + + if ownerType == "org" { + projectItem, resp, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, itemID, opts) + } else { + projectItem, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, itemID, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project item", + 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 project item", resp, body), nil, nil + } + + r, err := json.Marshal(projectItem) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func updateProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fieldValue map[string]any) (*mcp.CallToolResult, any, error) { + updatePayload, err := buildUpdateProjectItem(fieldValue) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var updatedItem *github.ProjectV2Item + + if ownerType == "org" { + updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload) + } else { + updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectUpdateFailedError, + 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, ProjectUpdateFailedError, resp, body), nil, nil + } + r, err := json.Marshal(updatedItem) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var err error + + if ownerType == "org" { + resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID) + } else { + resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectDeleteFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent { + 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, ProjectDeleteFailedError, resp, body), nil, nil + } + return utils.NewToolResultText("project item successfully deleted"), nil, nil +} + +// addProjectItem adds an item to a project by resolving the issue/PR number to a node ID +func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, itemOwner, itemRepo string, itemNumber int, itemType string) (*mcp.CallToolResult, any, error) { + if itemType != "issue" && itemType != "pull_request" { + return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil + } + + // Resolve the item number to a node ID + var nodeID githubv4.ID + var err error + if itemType == "issue" { + nodeID, err = resolveIssueNodeID(ctx, gqlClient, itemOwner, itemRepo, itemNumber) + } else { + nodeID, err = resolvePullRequestNodeID(ctx, gqlClient, itemOwner, itemRepo, itemNumber) + } + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to resolve %s: %v", itemType, err)), nil, nil + } + + // Use GraphQL to add the item to the project + var mutation struct { + AddProjectV2ItemByID struct { + Item struct { + ID githubv4.ID + } + } `graphql:"addProjectV2ItemById(input: $input)"` + } + + // First, get the project ID + var projectIDQuery struct { + User struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` + } + var projectIDQueryOrg struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + } + + var projectID githubv4.ID + if ownerType == "org" { + err = gqlClient.Query(ctx, &projectIDQueryOrg, map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers + }) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil + } + projectID = projectIDQueryOrg.Organization.ProjectV2.ID + } else { + err = gqlClient.Query(ctx, &projectIDQuery, map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers + }) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil + } + projectID = projectIDQuery.User.ProjectV2.ID + } + + // Add the item to the project + input := githubv4.AddProjectV2ItemByIdInput{ + ProjectID: projectID, + ContentID: nodeID, + } + + err = gqlClient.Mutate(ctx, &mutation, input, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf(ProjectAddFailedError+": %v", err)), nil, nil + } + + result := map[string]any{ + "id": mutation.AddProjectV2ItemByID.Item.ID, + "message": fmt.Sprintf("Successfully added %s %s/%s#%d to project %s/%d", itemType, itemOwner, itemRepo, itemNumber, owner, projectNumber), + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil } type pageInfo struct { @@ -1090,3 +2063,77 @@ func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsP return opts, nil } + +// resolveIssueNodeID resolves an issue number to its GraphQL node ID +func resolveIssueNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int) (githubv4.ID, error) { + var query struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + variables := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "issueNumber": githubv4.Int(int32(issueNumber)), //nolint:gosec // Issue numbers are small integers + } + + err := gqlClient.Query(ctx, &query, variables) + if err != nil { + return "", fmt.Errorf("failed to resolve issue %s/%s#%d: %w", owner, repo, issueNumber, err) + } + + return query.Repository.Issue.ID, nil +} + +// resolvePullRequestNodeID resolves a pull request number to its GraphQL node ID +func resolvePullRequestNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, prNumber int) (githubv4.ID, error) { + var query struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + } `graphql:"pullRequest(number: $prNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + variables := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "prNumber": githubv4.Int(int32(prNumber)), //nolint:gosec // PR numbers are small integers + } + + err := gqlClient.Query(ctx, &query, variables) + if err != nil { + return "", fmt.Errorf("failed to resolve pull request %s/%s#%d: %w", owner, repo, prNumber, err) + } + + return query.Repository.PullRequest.ID, nil +} + +// detectOwnerType attempts to detect the owner type by trying both user and org +// Returns the detected type ("user" or "org") and any error encountered +func detectOwnerType(ctx context.Context, client *github.Client, owner string, projectNumber int) (string, error) { + // Try user first (more common for personal projects) + _, resp, err := client.Projects.GetUserProject(ctx, owner, projectNumber) + if err == nil && resp.StatusCode == http.StatusOK { + _ = resp.Body.Close() + return "user", nil + } + if resp != nil { + _ = resp.Body.Close() + } + + // If not found (404) or other error, try org + _, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) + if err == nil && resp.StatusCode == http.StatusOK { + _ = resp.Body.Close() + return "org", nil + } + if resp != nil { + _ = resp.Body.Close() + } + + return "", fmt.Errorf("could not determine owner type for %s with project %d: owner is neither a user nor an org with this project", owner, projectNumber) +} diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index e443b9ecd..24163ef90 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -3,15 +3,15 @@ package github import ( "context" "encoding/json" - "io" "net/http" "testing" + "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" gh "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -45,15 +45,9 @@ func Test_ListProjects(t *testing.T) { }{ { name: "success organization", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgProjects)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2: mockResponse(t, http.StatusOK, orgProjects), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -63,15 +57,9 @@ func Test_ListProjects(t *testing.T) { }, { name: "success user", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{username}/projectsV2", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(userProjects)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsername: mockResponse(t, http.StatusOK, userProjects), + }), requestArgs: map[string]interface{}{ "owner": "octocat", "owner_type": "user", @@ -81,21 +69,12 @@ func Test_ListProjects(t *testing.T) { }, { name: "success organization with pagination & query", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("per_page") == "50" && q.Get("q") == "roadmap" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgProjects)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2: expectQueryParams(t, map[string]string{ + "per_page": "50", + "q": "roadmap", + }).andThen(mockResponse(t, http.StatusOK, orgProjects)), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -107,12 +86,9 @@ func Test_ListProjects(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -122,7 +98,7 @@ func Test_ListProjects(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner_type": "org", }, @@ -130,7 +106,7 @@ func Test_ListProjects(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", }, @@ -204,12 +180,9 @@ func Test_GetProject(t *testing.T) { }{ { name: "success organization project fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/123", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, project), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), + }), requestArgs: map[string]interface{}{ "project_number": float64(123), "owner": "octo-org", @@ -219,12 +192,9 @@ func Test_GetProject(t *testing.T) { }, { name: "success user project fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{username}/projectsV2/456", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, project), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusOK, project), + }), requestArgs: map[string]interface{}{ "project_number": float64(456), "owner": "octocat", @@ -234,12 +204,9 @@ func Test_GetProject(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/999", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]interface{}{ "project_number": float64(999), "owner": "octo-org", @@ -250,7 +217,7 @@ func Test_GetProject(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -259,7 +226,7 @@ func Test_GetProject(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "project_number": float64(123), "owner_type": "org", @@ -268,7 +235,7 @@ func Test_GetProject(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "project_number": float64(123), "owner": "octo-org", @@ -343,15 +310,9 @@ func Test_ListProjectFields(t *testing.T) { }{ { name: "success organization fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgFields)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, orgFields), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -361,21 +322,11 @@ func Test_ListProjectFields(t *testing.T) { }, { name: "success user fields with per_page override", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("per_page") == "50" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(userFields)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2FieldsByUsernameByProject: expectQueryParams(t, map[string]string{ + "per_page": "50", + }).andThen(mockResponse(t, http.StatusOK, userFields)), + }), requestArgs: map[string]interface{}{ "owner": "octocat", "owner_type": "user", @@ -386,12 +337,9 @@ func Test_ListProjectFields(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -402,7 +350,7 @@ func Test_ListProjectFields(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner_type": "org", "project_number": 10, @@ -411,7 +359,7 @@ func Test_ListProjectFields(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", "project_number": 10, @@ -420,7 +368,7 @@ func Test_ListProjectFields(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -500,12 +448,9 @@ func Test_GetProjectField(t *testing.T) { }{ { name: "success organization field", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgField), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusOK, orgField), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -516,12 +461,9 @@ func Test_GetProjectField(t *testing.T) { }, { name: "success user field", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userField), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2FieldsByUsernameByProjectByFieldID: mockResponse(t, http.StatusOK, userField), + }), requestArgs: map[string]any{ "owner": "octocat", "owner_type": "user", @@ -532,12 +474,9 @@ func Test_GetProjectField(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -549,7 +488,7 @@ func Test_GetProjectField(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner_type": "org", "project_number": float64(10), @@ -559,7 +498,7 @@ func Test_GetProjectField(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "project_number": float64(10), @@ -569,7 +508,7 @@ func Test_GetProjectField(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -579,7 +518,7 @@ func Test_GetProjectField(t *testing.T) { }, { name: "missing field_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -671,12 +610,9 @@ func Test_ListProjectItems(t *testing.T) { }{ { name: "success organization items", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgItems), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, orgItems), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -686,21 +622,12 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "success organization items with fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("fields") == "123,456,789" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgItems)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: expectQueryParams(t, map[string]string{ + "fields": "123,456,789", + "per_page": "50", + }).andThen(mockResponse(t, http.StatusOK, orgItems)), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -711,12 +638,9 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "success user items", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userItems), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ItemsByUsernameByProject: mockResponse(t, http.StatusOK, userItems), + }), requestArgs: map[string]interface{}{ "owner": "octocat", "owner_type": "user", @@ -726,21 +650,12 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "success with pagination and query", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("per_page") == "50" && q.Get("q") == "bug" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgItems)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: expectQueryParams(t, map[string]string{ + "per_page": "50", + "q": "bug", + }).andThen(mockResponse(t, http.StatusOK, orgItems)), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -752,12 +667,9 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -768,7 +680,7 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner_type": "org", "project_number": float64(10), @@ -777,7 +689,7 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", "project_number": float64(10), @@ -786,7 +698,7 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -877,12 +789,9 @@ func Test_GetProjectItem(t *testing.T) { }{ { name: "success organization item", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgItem), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, orgItem), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -893,21 +802,11 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "success organization item with fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("fields") == "123,456" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgItem)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: expectQueryParams(t, map[string]string{ + "fields": "123,456", + }).andThen(mockResponse(t, http.StatusOK, orgItem)), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -919,12 +818,9 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "success user item", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userItem), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ItemsByUsernameByProjectByItemID: mockResponse(t, http.StatusOK, userItem), + }), requestArgs: map[string]any{ "owner": "octocat", "owner_type": "user", @@ -935,12 +831,9 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -952,7 +845,7 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner_type": "org", "project_number": float64(10), @@ -962,7 +855,7 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "project_number": float64(10), @@ -972,7 +865,7 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -982,7 +875,7 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1086,24 +979,12 @@ func Test_AddProjectItem(t *testing.T) { }{ { name: "success organization issue", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Type string `json:"type"` - ID int `json:"id"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - assert.Equal(t, "Issue", payload.Type) - assert.Equal(t, 9876, payload.ID) - w.WriteHeader(http.StatusCreated) - _, _ = w.Write(mock.MustMarshal(orgItem)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostOrgsProjectsV2ItemsByProject: expectRequestBody(t, map[string]any{ + "type": "Issue", + "id": float64(9876), + }).andThen(mockResponse(t, http.StatusCreated, orgItem)), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1117,24 +998,12 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "success user pull request", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodPost}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Type string `json:"type"` - ID int `json:"id"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - assert.Equal(t, "PullRequest", payload.Type) - assert.Equal(t, 7654, payload.ID) - w.WriteHeader(http.StatusCreated) - _, _ = w.Write(mock.MustMarshal(userItem)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostUsersProjectsV2ItemsByUsernameByProject: expectRequestBody(t, map[string]any{ + "type": "PullRequest", + "id": float64(7654), + }).andThen(mockResponse(t, http.StatusCreated, userItem)), + }), requestArgs: map[string]any{ "owner": "octocat", "owner_type": "user", @@ -1148,12 +1017,9 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1166,7 +1032,7 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner_type": "org", "project_number": float64(1), @@ -1177,7 +1043,7 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "project_number": float64(1), @@ -1188,7 +1054,7 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1199,7 +1065,7 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "missing item_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1210,7 +1076,7 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1310,27 +1176,11 @@ func Test_UpdateProjectItem(t *testing.T) { }{ { name: "success organization update", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Fields []struct { - ID int `json:"id"` - Value interface{} `json:"value"` - } `json:"fields"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - require.Len(t, payload.Fields, 1) - assert.Equal(t, 101, payload.Fields[0].ID) - assert.Equal(t, "Done", payload.Fields[0].Value) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgUpdatedItem)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: expectRequestBody(t, map[string]any{ + "fields": []any{map[string]any{"id": float64(101), "value": "Done"}}, + }).andThen(mockResponse(t, http.StatusOK, orgUpdatedItem)), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1345,27 +1195,11 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "success user update", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Fields []struct { - ID int `json:"id"` - Value interface{} `json:"value"` - } `json:"fields"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - require.Len(t, payload.Fields, 1) - assert.Equal(t, 202, payload.Fields[0].ID) - assert.Equal(t, 42.0, payload.Fields[0].Value) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(userUpdatedItem)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchUsersProjectsV2ItemsByUsernameByProjectByItemID: expectRequestBody(t, map[string]any{ + "fields": []any{map[string]any{"id": float64(202), "value": float64(42)}}, + }).andThen(mockResponse(t, http.StatusOK, userUpdatedItem)), + }), requestArgs: map[string]any{ "owner": "octocat", "owner_type": "user", @@ -1380,12 +1214,9 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1401,7 +1232,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner_type": "org", "project_number": float64(1), @@ -1415,7 +1246,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "project_number": float64(1), @@ -1429,7 +1260,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1443,7 +1274,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1457,7 +1288,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "missing updated_field", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1468,7 +1299,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "updated_field not object", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1480,7 +1311,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "updated_field missing id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1492,7 +1323,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "updated_field missing value", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1580,14 +1411,11 @@ func Test_DeleteProjectItem(t *testing.T) { }{ { name: "success organization delete", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteOrgsProjectsV2ItemsByProjectByItemID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1598,14 +1426,11 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "success user delete", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteUsersProjectsV2ItemsByUsernameByProjectByItemID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + }), requestArgs: map[string]any{ "owner": "octocat", "owner_type": "user", @@ -1616,12 +1441,9 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1633,7 +1455,7 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner_type": "org", "project_number": float64(1), @@ -1643,7 +1465,7 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "project_number": float64(1), @@ -1653,7 +1475,7 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1663,7 +1485,7 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1709,3 +1531,802 @@ func Test_DeleteProjectItem(t *testing.T) { }) } } + +// Tests for consolidated project tools + +func Test_ProjectsList(t *testing.T) { + // Verify tool definition once + toolDef := ProjectsList(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "projects_list", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "owner_type") + assert.Contains(t, inputSchema.Properties, "project_number") + assert.Contains(t, inputSchema.Properties, "query") + assert.Contains(t, inputSchema.Properties, "fields") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner"}) +} + +func Test_ProjectsList_ListProjects(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) + + orgProjects := []map[string]any{{"id": 1, "node_id": "NODE1", "title": "Org Project"}} + userProjects := []map[string]any{{"id": 2, "node_id": "NODE2", "title": "User Project"}} + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedLength int + }{ + { + name: "success organization", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2: mockResponse(t, http.StatusOK, orgProjects), + }), + requestArgs: map[string]any{ + "method": "list_projects", + "owner": "octo-org", + "owner_type": "org", + }, + expectError: false, + expectedLength: 1, + }, + { + name: "success user", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsername: mockResponse(t, http.StatusOK, userProjects), + }), + requestArgs: map[string]any{ + "method": "list_projects", + "owner": "octocat", + "owner_type": "user", + }, + expectError: false, + expectedLength: 1, + }, + { + name: "missing required parameter method", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + expectedErrMsg: "missing required parameter: method", + }, + { + name: "unknown method", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "unknown_method", + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + expectedErrMsg: "unknown method: unknown_method", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + textContent := getTextResult(t, result) + + if tc.expectError { + if tc.expectedErrMsg != "" { + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + } + return + } + + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + projects, ok := response["projects"].([]interface{}) + require.True(t, ok) + assert.Equal(t, tc.expectedLength, len(projects)) + }) + } +} + +func Test_ProjectsList_ListProjectFields(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) + + fields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}} + + t.Run("success organization", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, fields), + }) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_fields", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + fieldsList, ok := response["fields"].([]interface{}) + require.True(t, ok) + assert.Equal(t, 1, len(fieldsList)) + }) + + t.Run("missing project_number", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_fields", + "owner": "octo-org", + "owner_type": "org", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: project_number") + }) +} + +func Test_ProjectsList_ListProjectItems(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) + + items := []map[string]any{{"id": 1001, "archived_at": nil, "content": map[string]any{"title": "Issue 1"}}} + + t.Run("success organization", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items), + }) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + itemsList, ok := response["items"].([]interface{}) + require.True(t, ok) + assert.Equal(t, 1, len(itemsList)) + }) +} + +func Test_ProjectsGet(t *testing.T) { + // Verify tool definition once + toolDef := ProjectsGet(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "projects_get", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "owner_type") + assert.Contains(t, inputSchema.Properties, "project_number") + assert.Contains(t, inputSchema.Properties, "field_id") + assert.Contains(t, inputSchema.Properties, "item_id") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "project_number"}) +} + +func Test_ProjectsGet_GetProject(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + project := map[string]any{"id": 123, "title": "Project Title"} + + t.Run("success organization", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), + }) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + }) + + t.Run("unknown method", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "unknown_method", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "unknown method: unknown_method") + }) +} + +func Test_ProjectsGet_GetProjectField(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + field := map[string]any{"id": 101, "name": "Status", "data_type": "single_select"} + + t.Run("success organization", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusOK, field), + }) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_field", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "field_id": float64(101), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + }) + + t.Run("missing field_id", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_field", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: field_id") + }) +} + +func Test_ProjectsGet_GetProjectItem(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + item := map[string]any{"id": 1001, "archived_at": nil, "content": map[string]any{"title": "Issue 1"}} + + t.Run("success organization", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, item), + }) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + }) + + t.Run("missing item_id", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: item_id") + }) +} + +func Test_ProjectsWrite(t *testing.T) { + // Verify tool definition once + toolDef := ProjectsWrite(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "projects_write", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "owner_type") + assert.Contains(t, inputSchema.Properties, "project_number") + assert.Contains(t, inputSchema.Properties, "item_id") + assert.Contains(t, inputSchema.Properties, "item_type") + assert.Contains(t, inputSchema.Properties, "item_owner") + assert.Contains(t, inputSchema.Properties, "item_repo") + assert.Contains(t, inputSchema.Properties, "issue_number") + assert.Contains(t, inputSchema.Properties, "pull_request_number") + assert.Contains(t, inputSchema.Properties, "updated_field") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "project_number"}) + + // Verify DestructiveHint is set + assert.NotNil(t, toolDef.Tool.Annotations) + assert.NotNil(t, toolDef.Tool.Annotations.DestructiveHint) + assert.True(t, *toolDef.Tool.Annotations.DestructiveHint) +} + +func Test_ProjectsWrite_AddProjectItem(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + t.Run("success organization with issue", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + // Mock resolveIssueNodeID query + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("item-owner"), + "repo": githubv4.String("item-repo"), + "issueNumber": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "id": "I_issue123", + }, + }, + }), + ), + // Mock project ID query for org + githubv4mock.NewQueryMatcher( + struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octo-org"), + "projectNumber": githubv4.Int(1), + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{ + "id": "PVT_project1", + }, + }, + }), + ), + // Mock addProjectV2ItemById mutation + githubv4mock.NewMutationMatcher( + struct { + AddProjectV2ItemByID struct { + Item struct { + ID githubv4.ID + } + } `graphql:"addProjectV2ItemById(input: $input)"` + }{}, + githubv4.AddProjectV2ItemByIdInput{ + ProjectID: githubv4.ID("PVT_project1"), + ContentID: githubv4.ID("I_issue123"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "addProjectV2ItemById": map[string]any{ + "item": map[string]any{ + "id": "PVTI_item1", + }, + }, + }), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "add_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_owner": "item-owner", + "item_repo": "item-repo", + "issue_number": float64(123), + "item_type": "issue", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + assert.Contains(t, response["message"], "Successfully added") + }) + + t.Run("success user with pull request", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + // Mock resolvePullRequestNodeID query + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + } `graphql:"pullRequest(number: $prNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("item-owner"), + "repo": githubv4.String("item-repo"), + "prNumber": githubv4.Int(456), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_pr456", + }, + }, + }), + ), + // Mock project ID query for user + githubv4mock.NewQueryMatcher( + struct { + User struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octo-user"), + "projectNumber": githubv4.Int(2), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "projectV2": map[string]any{ + "id": "PVT_project2", + }, + }, + }), + ), + // Mock addProjectV2ItemById mutation + githubv4mock.NewMutationMatcher( + struct { + AddProjectV2ItemByID struct { + Item struct { + ID githubv4.ID + } + } `graphql:"addProjectV2ItemById(input: $input)"` + }{}, + githubv4.AddProjectV2ItemByIdInput{ + ProjectID: githubv4.ID("PVT_project2"), + ContentID: githubv4.ID("PR_pr456"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "addProjectV2ItemById": map[string]any{ + "item": map[string]any{ + "id": "PVTI_item2", + }, + }, + }), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "add_project_item", + "owner": "octo-user", + "owner_type": "user", + "project_number": float64(2), + "item_owner": "item-owner", + "item_repo": "item-repo", + "pull_request_number": float64(456), + "item_type": "pull_request", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + assert.Contains(t, response["message"], "Successfully added") + }) + + t.Run("missing item_type", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient() + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "add_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_owner": "item-owner", + "item_repo": "item-repo", + "issue_number": float64(123), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: item_type") + }) + + t.Run("invalid item_type", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient() + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "add_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_owner": "item-owner", + "item_repo": "item-repo", + "issue_number": float64(123), + "item_type": "invalid_type", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "item_type must be either 'issue' or 'pull_request'") + }) + + t.Run("unknown method", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient() + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "unknown_method", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "unknown method: unknown_method") + }) +} + +func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + updatedItem := map[string]any{"id": 1001, "archived_at": nil} + + t.Run("success organization", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), + }) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + "updated_field": map[string]any{ + "id": float64(101), + "value": "In Progress", + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + }) + + t.Run("missing updated_field", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: updated_field") + }) +} + +func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + t.Run("success organization", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteOrgsProjectsV2ItemsByProjectByItemID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + }) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "delete_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "project item successfully deleted") + }) + + t.Run("missing item_id", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "delete_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: item_id") + }) +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index d51c14fa4..f546865b2 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -18,6 +18,7 @@ import ( "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/sanitize" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" ) @@ -69,6 +70,7 @@ Possible options: }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { @@ -518,6 +520,7 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -669,6 +672,7 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -950,6 +954,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1086,6 +1091,7 @@ func MergePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1203,6 +1209,7 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { result, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests") return result, nil, err @@ -1245,6 +1252,7 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1371,6 +1379,7 @@ Available methods: }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params PullRequestReviewWriteParams if err := mapstructure.Decode(args, ¶ms); err != nil { @@ -1493,7 +1502,7 @@ func SubmitPendingPullRequestReview(ctx context.Context, client *githubv4.Client "prNum": githubv4.Int(params.PullNumber), } - if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { + if err := client.Query(ctx, &getLatestReviewForViewerQuery, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get latest review for current user", err, @@ -1578,7 +1587,7 @@ func DeletePendingPullRequestReview(ctx context.Context, client *githubv4.Client "prNum": githubv4.Int(params.PullNumber), } - if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { + if err := client.Query(ctx, &getLatestReviewForViewerQuery, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get latest review for current user", err, @@ -1694,6 +1703,7 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params struct { Owner string @@ -1751,7 +1761,7 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S "prNum": githubv4.Int(params.PullNumber), } - if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { + if err := client.Query(ctx, &getLatestReviewForViewerQuery, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get latest review for current user", err, @@ -1846,6 +1856,7 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 3cb41515d..d2664479d 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -14,8 +14,6 @@ import ( "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" - - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -64,12 +62,9 @@ func Test_GetPullRequest(t *testing.T) { }{ { name: "successful PR fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockPR, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + }), requestArgs: map[string]interface{}{ "method": "get", "owner": "owner", @@ -81,15 +76,12 @@ func Test_GetPullRequest(t *testing.T) { }, { name: "PR fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + }), requestArgs: map[string]interface{}{ "method": "get", "owner": "owner", @@ -209,24 +201,17 @@ func Test_UpdatePullRequest(t *testing.T) { }{ { name: "successful PR update (title, body, base, maintainer_can_modify)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposPullsByOwnerByRepoByPullNumber, - // Expect the flat string based on previous test failure output and API docs - expectRequestBody(t, map[string]interface{}{ - "title": "Updated Test PR Title", - "body": "Updated test PR body.", - "base": "develop", - "maintainer_can_modify": false, - }).andThen( - mockResponse(t, http.StatusOK, mockUpdatedPR), - ), - ), - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockUpdatedPR, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + "title": "Updated Test PR Title", + "body": "Updated test PR body.", + "base": "develop", + "maintainer_can_modify": false, + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedPR), + ), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -241,20 +226,14 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "successful PR update (state)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposPullsByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{ - "state": "closed", - }).andThen( - mockResponse(t, http.StatusOK, mockClosedPR), - ), - ), - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockClosedPR, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + "state": "closed", + }).andThen( + mockResponse(t, http.StatusOK, mockClosedPR), + ), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockClosedPR), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -266,17 +245,10 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "successful PR update with reviewers", - mockedClient: mock.NewMockedHTTPClient( - // Mock for RequestReviewers call, returning the PR with reviewers - mock.WithRequestMatch( - mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, - mockPRWithReviewers, - ), - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockPRWithReviewers, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPRWithReviewers), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPRWithReviewers), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -288,20 +260,14 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "successful PR update (title only)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposPullsByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{ - "title": "Updated Test PR Title", - }).andThen( - mockResponse(t, http.StatusOK, mockUpdatedPR), - ), - ), - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockUpdatedPR, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + "title": "Updated Test PR Title", + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedPR), + ), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -313,7 +279,7 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "no update parameters provided", - mockedClient: mock.NewMockedHTTPClient(), // No API call expected + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), // No API call expected requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -325,15 +291,12 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "PR update fails (API error)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposPullsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -345,16 +308,12 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "request reviewers fails", - mockedClient: mock.NewMockedHTTPClient( - // Then reviewer request fails - mock.WithRequestMatchHandler( - mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Invalid reviewers"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid reviewers"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -553,12 +512,9 @@ func Test_UpdatePullRequest_Draft(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // For draft-only tests, we need to mock both GraphQL and the final REST GET call - restClient := github.NewClient(mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockUpdatedPR, - ), - )) + restClient := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR), + })) gqlClient := githubv4.NewClient(tc.mockedClient) serverTool := UpdatePullRequest(translations.NullTranslationHelper) @@ -642,20 +598,17 @@ func Test_ListPullRequests(t *testing.T) { }{ { name: "successful PRs listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "state": "all", - "sort": "created", - "direction": "desc", - "per_page": "30", - "page": "1", - }).andThen( - mockResponse(t, http.StatusOK, mockPRs), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "state": "all", + "sort": "created", + "direction": "desc", + "per_page": "30", + "page": "1", + }).andThen( + mockResponse(t, http.StatusOK, mockPRs), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -670,15 +623,12 @@ func Test_ListPullRequests(t *testing.T) { }, { name: "PRs listing fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -769,18 +719,15 @@ func Test_MergePullRequest(t *testing.T) { }{ { name: "successful merge", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsMergeByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{ - "commit_title": "Merge PR #42", - "commit_message": "Merging awesome feature", - "merge_method": "squash", - }).andThen( - mockResponse(t, http.StatusOK, mockMergeResult), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsMergeByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + "commit_title": "Merge PR #42", + "commit_message": "Merging awesome feature", + "merge_method": "squash", + }).andThen( + mockResponse(t, http.StatusOK, mockMergeResult), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -794,15 +741,12 @@ func Test_MergePullRequest(t *testing.T) { }, { name: "merge fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsMergeByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusMethodNotAllowed) - _, _ = w.Write([]byte(`{"message": "Pull request cannot be merged"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsMergeByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusMethodNotAllowed) + _, _ = w.Write([]byte(`{"message": "Pull request cannot be merged"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -911,23 +855,20 @@ func Test_SearchPullRequests(t *testing.T) { }{ { name: "successful pull request search with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr repo:owner/repo is:open", - "sort": "created", - "order": "desc", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:owner/repo is:open", + "sort": "created", + "order": "desc", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:owner/repo is:open", "sort": "created", @@ -940,23 +881,20 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "pull request search with owner and repo parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "repo:test-owner/test-repo is:pr draft:false", - "sort": "updated", - "order": "asc", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "repo:test-owner/test-repo is:pr draft:false", + "sort": "updated", + "order": "asc", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "draft:false", "owner": "test-owner", @@ -969,21 +907,18 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "pull request search with only owner parameter (should ignore it)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr feature", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr feature", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "feature", "owner": "test-owner", @@ -993,21 +928,18 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "pull request search with only repo parameter (should ignore it)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr review-required", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr review-required", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "review-required", "repo": "test-repo", @@ -1017,12 +949,9 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "pull request search with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetSearchIssues, - mockSearchResult, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult), + }), requestArgs: map[string]interface{}{ "query": "is:pr repo:owner/repo is:open", }, @@ -1031,21 +960,18 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "query with existing is:pr filter - no duplication", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr repo:github/github-mcp-server is:open draft:false", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:github/github-mcp-server is:open draft:false", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "is:pr repo:github/github-mcp-server is:open draft:false", }, @@ -1054,21 +980,18 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "query with existing repo: filter and conflicting owner/repo params - uses query filter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr repo:github/github-mcp-server author:octocat", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:github/github-mcp-server author:octocat", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:github/github-mcp-server author:octocat", "owner": "different-owner", @@ -1079,21 +1002,18 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "complex query with existing is:pr filter and OR operators", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", }, @@ -1102,15 +1022,12 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "search pull requests fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }, + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, @@ -1216,12 +1133,14 @@ func Test_GetPullRequestFiles(t *testing.T) { }{ { name: "successful files fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsFilesByOwnerByRepoByPullNumber, - mockFiles, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsFilesByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockFiles), + ), + }), requestArgs: map[string]interface{}{ "method": "get_files", "owner": "owner", @@ -1233,12 +1152,14 @@ func Test_GetPullRequestFiles(t *testing.T) { }, { name: "successful files fetch with pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsFilesByOwnerByRepoByPullNumber, - mockFiles, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsFilesByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockFiles), + ), + }), requestArgs: map[string]interface{}{ "method": "get_files", "owner": "owner", @@ -1252,15 +1173,17 @@ func Test_GetPullRequestFiles(t *testing.T) { }, { name: "files fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsFilesByOwnerByRepoByPullNumber, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsFilesByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }), ), - ), + }), requestArgs: map[string]interface{}{ "method": "get_files", "owner": "owner", @@ -1382,16 +1305,10 @@ func Test_GetPullRequestStatus(t *testing.T) { }{ { name: "successful status fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockPR, - ), - mock.WithRequestMatch( - mock.GetReposCommitsStatusByOwnerByRepoByRef, - mockStatus, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposCommitsStatusByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockStatus), + }), requestArgs: map[string]interface{}{ "method": "get_status", "owner": "owner", @@ -1403,15 +1320,12 @@ func Test_GetPullRequestStatus(t *testing.T) { }, { name: "PR fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), requestArgs: map[string]interface{}{ "method": "get_status", "owner": "owner", @@ -1423,19 +1337,13 @@ func Test_GetPullRequestStatus(t *testing.T) { }, { name: "status fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockPR, - ), - mock.WithRequestMatchHandler( - mock.GetReposCommitsStatusesByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposCommitsStatusesByOwnerByRepoByRef: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), requestArgs: map[string]interface{}{ "method": "get_status", "owner": "owner", @@ -1527,16 +1435,13 @@ func Test_UpdatePullRequestBranch(t *testing.T) { }{ { name: "successful branch update", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{ - "expected_head_sha": "abcd1234", - }).andThen( - mockResponse(t, http.StatusAccepted, mockUpdateResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + "expected_head_sha": "abcd1234", + }).andThen( + mockResponse(t, http.StatusAccepted, mockUpdateResult), ), - ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1548,14 +1453,11 @@ func Test_UpdatePullRequestBranch(t *testing.T) { }, { name: "branch update without expected SHA", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{}).andThen( - mockResponse(t, http.StatusAccepted, mockUpdateResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{}).andThen( + mockResponse(t, http.StatusAccepted, mockUpdateResult), ), - ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1566,15 +1468,12 @@ func Test_UpdatePullRequestBranch(t *testing.T) { }, { name: "branch update fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusConflict) - _, _ = w.Write([]byte(`{"message": "Merge conflict"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"message": "Merge conflict"}`)) + }), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1997,12 +1896,9 @@ func Test_GetPullRequestReviews(t *testing.T) { }{ { name: "successful reviews fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, - mockReviews, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsReviewsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockReviews), + }), requestArgs: map[string]interface{}{ "method": "get_reviews", "owner": "owner", @@ -2014,15 +1910,12 @@ func Test_GetPullRequestReviews(t *testing.T) { }, { name: "reviews fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsReviewsByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), requestArgs: map[string]interface{}{ "method": "get_reviews", "owner": "owner", @@ -2034,25 +1927,22 @@ func Test_GetPullRequestReviews(t *testing.T) { }, { name: "lockdown enabled filters reviews without push access", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, - []*github.PullRequestReview{ - { - ID: github.Ptr(int64(2030)), - State: github.Ptr("APPROVED"), - Body: github.Ptr("Maintainer review"), - User: &github.User{Login: github.Ptr("maintainer")}, - }, - { - ID: github.Ptr(int64(2031)), - State: github.Ptr("COMMENTED"), - Body: github.Ptr("External reviewer"), - User: &github.User{Login: github.Ptr("testuser")}, - }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsReviewsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, []*github.PullRequestReview{ + { + ID: github.Ptr(int64(2030)), + State: github.Ptr("APPROVED"), + Body: github.Ptr("Maintainer review"), + User: &github.User{Login: github.Ptr("maintainer")}, }, - ), - ), + { + ID: github.Ptr(int64(2031)), + State: github.Ptr("COMMENTED"), + Body: github.Ptr("External reviewer"), + User: &github.User{Login: github.Ptr("testuser")}, + }, + }), + }), gqlHTTPClient: newRepoAccessHTTPClient(), requestArgs: map[string]interface{}{ "method": "get_reviews", @@ -2183,21 +2073,18 @@ func Test_CreatePullRequest(t *testing.T) { }{ { name: "successful PR creation", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposPullsByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ - "title": "Test PR", - "body": "This is a test PR", - "head": "feature-branch", - "base": "main", - "draft": false, - "maintainer_can_modify": true, - }).andThen( - mockResponse(t, http.StatusCreated, mockPR), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsByOwnerByRepo: expectRequestBody(t, map[string]interface{}{ + "title": "Test PR", + "body": "This is a test PR", + "head": "feature-branch", + "base": "main", + "draft": false, + "maintainer_can_modify": true, + }).andThen( + mockResponse(t, http.StatusCreated, mockPR), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -2213,7 +2100,7 @@ func Test_CreatePullRequest(t *testing.T) { }, { name: "missing required parameter", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -2224,15 +2111,12 @@ func Test_CreatePullRequest(t *testing.T) { }, { name: "PR creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposPullsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message":"Validation failed","errors":[{"resource":"PullRequest","code":"invalid"}]}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"Validation failed","errors":[{"resource":"PullRequest","code":"invalid"}]}`)) + }), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -2535,19 +2419,16 @@ func Test_RequestCopilotReview(t *testing.T) { }{ { name: "successful request", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, - expect(t, expectations{ - path: "/repos/owner/repo/pulls/1/requested_reviewers", - requestBody: map[string]any{ - "reviewers": []any{"copilot-pull-request-reviewer[bot]"}, - }, - }).andThen( - mockResponse(t, http.StatusCreated, mockPR), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: expect(t, expectations{ + path: "/repos/owner/repo/pulls/1/requested_reviewers", + requestBody: map[string]any{ + "reviewers": []any{"copilot-pull-request-reviewer[bot]"}, + }, + }).andThen( + mockResponse(t, http.StatusCreated, mockPR), ), - ), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -2557,15 +2438,12 @@ func Test_RequestCopilotReview(t *testing.T) { }, { name: "request fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -3234,15 +3112,11 @@ index 5d6e7b2..8a4f5c3 100644 "repo": "repo", "pullNumber": float64(42), }, - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepoByPullNumber, - // Should also expect Accept header to be application/vnd.github.v3.diff - expectPath(t, "/repos/owner/repo/pulls/42").andThen( - mockResponse(t, http.StatusOK, stubbedDiff), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: expectPath(t, "/repos/owner/repo/pulls/42").andThen( + mockResponse(t, http.StatusOK, stubbedDiff), ), - ), + }), expectToolError: false, }, } diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index c31bb7df2..f6203f39f 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -12,7 +12,7 @@ import ( 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/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -54,6 +54,7 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "sha"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -150,6 +151,7 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -249,6 +251,7 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -362,6 +365,7 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the Required: []string{"owner", "repo", "path", "content", "message", "branch"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -545,6 +549,7 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"name"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { name, err := RequiredParam[string](args, "name") if err != nil { @@ -651,6 +656,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -834,6 +840,7 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -940,6 +947,7 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "path", "message", "branch"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1119,6 +1127,7 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "branch"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1249,6 +1258,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "branch", "files", "message"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1279,28 +1289,74 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { } // Get the reference for the branch + var repositoryIsEmpty bool + var branchNotFound bool ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get branch reference", - resp, - err, - ), nil, nil + ghErr, isGhErr := err.(*github.ErrorResponse) + if isGhErr { + if ghErr.Response.StatusCode == http.StatusConflict && ghErr.Message == "Git Repository is empty." { + repositoryIsEmpty = true + } else if ghErr.Response.StatusCode == http.StatusNotFound { + branchNotFound = true + } + } + + if !repositoryIsEmpty && !branchNotFound { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get branch reference", + resp, + err, + ), nil, nil + } + } + // Only close resp if it's not nil and not an error case where resp might be nil + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() } - 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 + var baseCommit *github.Commit + if !repositoryIsEmpty { + if branchNotFound { + ref, err = createReferenceFromDefaultBranch(ctx, client, owner, repo, branch) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create branch from default: %v", err)), nil, nil + } + } + + // Get the commit object that the branch points to + baseCommit, resp, err = client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get base commit", + resp, + err, + ), nil, nil + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + } else { + var base *github.Commit + // Repository is empty, need to initialize it first + ref, base, err = initializeRepository(ctx, client, owner, repo) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to initialize repository: %v", err)), nil, nil + } + + defaultBranch := strings.TrimPrefix(*ref.Ref, "refs/heads/") + if branch != defaultBranch { + // Create the requested branch from the default branch + ref, err = createReferenceFromDefaultBranch(ctx, client, owner, repo, branch) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create branch from default: %v", err)), nil, nil + } + } + + baseCommit = base } - defer func() { _ = resp.Body.Close() }() - // Create tree entries for all files + // Create tree entries for all files (or remaining files if empty repo) var entries []*github.TreeEntry for _, file := range filesObj { @@ -1328,7 +1384,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { }) } - // Create a new tree with the file entries + // Create a new tree with the file entries (baseCommit is now guaranteed to exist) newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, entries) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, @@ -1337,9 +1393,11 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { err, ), nil, nil } - defer func() { _ = resp.Body.Close() }() + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } - // Create a new commit + // Create a new commit (baseCommit always has a value now) commit := github.Commit{ Message: github.Ptr(message), Tree: newTree, @@ -1353,7 +1411,9 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { err, ), nil, nil } - defer func() { _ = resp.Body.Close() }() + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } // Update the reference to point to the new commit ref.Object.SHA = newCommit.SHA @@ -1406,6 +1466,7 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1488,6 +1549,7 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "tag"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1581,6 +1643,7 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1655,6 +1718,7 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1723,6 +1787,7 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "tag"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1770,244 +1835,6 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool ) } -// 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 -} - -// looksLikeSHA returns true if the string appears to be a Git commit SHA. -// A SHA is a 40-character hexadecimal string. -func looksLikeSHA(s string) bool { - if len(s) != 40 { - return false - } - for _, c := range s { - if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { - return false - } - } - return true -} - -// 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. -// -// 1a. If `sha` is empty but `ref` looks like a commit SHA (40 hexadecimal characters), -// it is returned as-is without any API calls or reference resolution. -// -// 2. If no `sha` is provided and `ref` does not look like a SHA, the function resolves -// the `ref` string into a fully-qualified format (e.g., "refs/heads/main") by trying -// the following steps in order: -// a). **Empty Ref:** If `ref` is empty, the repository's default branch is used. -// b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully -// qualified and used as-is. -// c). **Partially-Qualified:** If `ref` starts with "heads/" or "tags/", it is -// prefixed with "refs/" to make it fully-qualified. -// d). **Short Name:** Otherwise, the `ref` is treated as a short name. The function -// first attempts to resolve it as a branch ("refs/heads/"). If that -// returns a 404 Not Found error, it then attempts to resolve it as a tag -// ("refs/tags/"). -// -// 3. **Final Lookup:** Once a fully-qualified ref is determined, a final API call -// is made to fetch that reference's definitive commit SHA. -// -// Any unexpected (non-404) errors during the resolution process are returned -// immediately. All API errors are logged with rich context to aid diagnostics. -func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, bool, error) { - // 1) If SHA explicitly provided, it's the highest priority. - if sha != "" { - return &raw.ContentOpts{Ref: "", SHA: sha}, false, nil - } - - // 1a) If sha is empty but ref looks like a SHA, return it without changes - if looksLikeSHA(ref) { - return &raw.ContentOpts{Ref: "", SHA: ref}, false, 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 - var fallbackUsed bool - - switch { - case originalRef == "": - // 2a) If ref is empty, determine the default branch. - reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) - if err != nil { - return nil, false, err // Error is already wrapped in resolveDefaultBranch. - } - ref = reference.GetRef() - 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 { - if originalRef == "main" { - reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) - if err != nil { - return nil, false, err // Error is already wrapped in resolveDefaultBranch. - } - // Update ref to the actual default branch ref so the note can be generated - ref = reference.GetRef() - fallbackUsed = true - break - } - return nil, false, 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, false, 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, false, 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 { - if ref == "refs/heads/main" { - reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) - if err != nil { - return nil, false, err // Error is already wrapped in resolveDefaultBranch. - } - // Update ref to the actual default branch ref so the note can be generated - ref = reference.GetRef() - fallbackUsed = true - } else { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) - return nil, false, fmt.Errorf("failed to get final reference for %q: %w", ref, err) - } - } - } - - sha = reference.GetObject().GetSHA() - return &raw.ContentOpts{Ref: ref, SHA: sha}, fallbackUsed, nil -} - -func resolveDefaultBranch(ctx context.Context, githubClient *github.Client, owner, repo string) (*github.Reference, error) { - 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) - } - - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() - } - - defaultBranch := repoInfo.GetDefaultBranch() - - defaultRef, resp, err := githubClient.Git.GetRef(ctx, owner, repo, "heads/"+defaultBranch) - if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get default branch reference", resp, err) - return nil, fmt.Errorf("failed to get default branch reference: %w", err) - } - - if resp != nil && resp.Body != nil { - defer func() { _ = resp.Body.Close() }() - } - - return defaultRef, 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( @@ -2039,6 +1866,7 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.Ser }, }), }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { username, err := OptionalParam[string](args, "username") if err != nil { @@ -2166,6 +1994,7 @@ func StarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -2230,6 +2059,7 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/repositories_helper.go b/pkg/github/repositories_helper.go new file mode 100644 index 000000000..de5065d48 --- /dev/null +++ b/pkg/github/repositories_helper.go @@ -0,0 +1,329 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v79/github" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// initializeRepository creates an initial commit in an empty repository and returns the default branch ref and base commit +func initializeRepository(ctx context.Context, client *github.Client, owner, repo string) (ref *github.Reference, baseCommit *github.Commit, err error) { + // First, we need to check what the default branch in this empty repo should be: + repository, resp, err := client.Repositories.Get(ctx, owner, repo) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository", resp, err) + return nil, nil, fmt.Errorf("failed to get repository: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + defaultBranch := repository.GetDefaultBranch() + + fileOpts := &github.RepositoryContentFileOptions{ + Message: github.Ptr("Initial commit"), + Content: []byte(""), + Branch: github.Ptr(defaultBranch), + } + + // Create an initial empty commit to create the default branch + createResp, resp, err := client.Repositories.CreateFile(ctx, owner, repo, "README.md", fileOpts) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to create initial file", resp, err) + return nil, nil, fmt.Errorf("failed to create initial file: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + // Get the commit that was just created to use as base for remaining files + baseCommit, resp, err = client.Git.GetCommit(ctx, owner, repo, *createResp.Commit.SHA) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get initial commit", resp, err) + return nil, nil, fmt.Errorf("failed to get initial commit: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + ref, resp, err = client.Git.GetRef(ctx, owner, repo, "refs/heads/"+defaultBranch) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) + return nil, nil, fmt.Errorf("failed to get branch reference after initial commit: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + return ref, baseCommit, nil +} + +// createReferenceFromDefaultBranch creates a new branch reference from the repository's default branch +func createReferenceFromDefaultBranch(ctx context.Context, client *github.Client, owner, repo, branch string) (*github.Reference, error) { + defaultRef, err := resolveDefaultBranch(ctx, client, owner, repo) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to resolve default branch", nil, err) + return nil, fmt.Errorf("failed to resolve default branch: %w", err) + } + + // Create the new branch reference + createdRef, resp, err := client.Git.CreateRef(ctx, owner, repo, github.CreateRef{ + Ref: "refs/heads/" + branch, + SHA: *defaultRef.Object.SHA, + }) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to create new branch reference", resp, err) + return nil, fmt.Errorf("failed to create new branch reference: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + return createdRef, 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 +} + +// looksLikeSHA returns true if the string appears to be a Git commit SHA. +// A SHA is a 40-character hexadecimal string. +func looksLikeSHA(s string) bool { + if len(s) != 40 { + return false + } + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + return false + } + } + return true +} + +// 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. +// +// 1a. If `sha` is empty but `ref` looks like a commit SHA (40 hexadecimal characters), +// it is returned as-is without any API calls or reference resolution. +// +// 2. If no `sha` is provided and `ref` does not look like a SHA, the function resolves +// the `ref` string into a fully-qualified format (e.g., "refs/heads/main") by trying +// the following steps in order: +// a). **Empty Ref:** If `ref` is empty, the repository's default branch is used. +// b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully +// qualified and used as-is. +// c). **Partially-Qualified:** If `ref` starts with "heads/" or "tags/", it is +// prefixed with "refs/" to make it fully-qualified. +// d). **Short Name:** Otherwise, the `ref` is treated as a short name. The function +// first attempts to resolve it as a branch ("refs/heads/"). If that +// returns a 404 Not Found error, it then attempts to resolve it as a tag +// ("refs/tags/"). +// +// 3. **Final Lookup:** Once a fully-qualified ref is determined, a final API call +// is made to fetch that reference's definitive commit SHA. +// +// Any unexpected (non-404) errors during the resolution process are returned +// immediately. All API errors are logged with rich context to aid diagnostics. +func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, bool, error) { + // 1) If SHA explicitly provided, it's the highest priority. + if sha != "" { + return &raw.ContentOpts{Ref: "", SHA: sha}, false, nil + } + + // 1a) If sha is empty but ref looks like a SHA, return it without changes + if looksLikeSHA(ref) { + return &raw.ContentOpts{Ref: "", SHA: ref}, false, 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 + var fallbackUsed bool + + switch { + case originalRef == "": + // 2a) If ref is empty, determine the default branch. + reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) + if err != nil { + return nil, false, err // Error is already wrapped in resolveDefaultBranch. + } + ref = reference.GetRef() + 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 { + if originalRef == "main" { + reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) + if err != nil { + return nil, false, err // Error is already wrapped in resolveDefaultBranch. + } + // Update ref to the actual default branch ref so the note can be generated + ref = reference.GetRef() + fallbackUsed = true + break + } + return nil, false, 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, false, 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, false, 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 { + if ref == "refs/heads/main" { + reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) + if err != nil { + return nil, false, err // Error is already wrapped in resolveDefaultBranch. + } + // Update ref to the actual default branch ref so the note can be generated + ref = reference.GetRef() + fallbackUsed = true + } else { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) + return nil, false, fmt.Errorf("failed to get final reference for %q: %w", ref, err) + } + } + } + + sha = reference.GetObject().GetSHA() + return &raw.ContentOpts{Ref: ref, SHA: sha}, fallbackUsed, nil +} + +func resolveDefaultBranch(ctx context.Context, githubClient *github.Client, owner, repo string) (*github.Reference, error) { + 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) + } + + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + + defaultBranch := repoInfo.GetDefaultBranch() + + defaultRef, resp, err := githubClient.Git.GetRef(ctx, owner, repo, "heads/"+defaultBranch) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get default branch reference", resp, err) + return nil, fmt.Errorf("failed to get default branch reference: %w", err) + } + + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + return defaultRef, nil +} diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 8b5dab098..d91af8851 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -2,6 +2,7 @@ package github import ( "context" + "encoding/base64" "encoding/json" "net/http" "net/url" @@ -15,7 +16,6 @@ import ( "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -73,36 +73,25 @@ func Test_GetFileContents(t *testing.T) { }{ { name: "successful text content fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -118,36 +107,25 @@ func Test_GetFileContents(t *testing.T) { }, { name: "successful file blob content fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("test.png"), - Path: github.Ptr("test.png"), - SHA: github.Ptr("def456"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "image/png") - _, _ = w.Write(mockRawContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("test.png"), + Path: github.Ptr("test.png"), + SHA: github.Ptr("def456"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(mockRawContent) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -163,36 +141,25 @@ func Test_GetFileContents(t *testing.T) { }, { name: "successful PDF file content fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("document.pdf"), - Path: github.Ptr("document.pdf"), - SHA: github.Ptr("pdf123"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/pdf") - _, _ = w.Write(mockRawContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("document.pdf"), + Path: github.Ptr("document.pdf"), + SHA: github.Ptr("pdf123"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/pdf") + _, _ = w.Write(mockRawContent) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -208,36 +175,16 @@ func Test_GetFileContents(t *testing.T) { }, { name: "successful directory content fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - expectQueryParams(t, map[string]string{}).andThen( - mockResponse(t, http.StatusOK, mockDirContent), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposContentsByOwnerByRepoByPath: expectQueryParams(t, map[string]string{}).andThen( + mockResponse(t, http.StatusOK, mockDirContent), ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByPath, - expectQueryParams(t, map[string]string{ - "branch": "main", - }).andThen( - mockResponse(t, http.StatusNotFound, nil), - ), + GetRawReposContentsByOwnerByRepoByPath: expectQueryParams(t, map[string]string{"branch": "main"}).andThen( + mockResponse(t, http.StatusNotFound, nil), ), - ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -248,36 +195,25 @@ func Test_GetFileContents(t *testing.T) { }, { name: "successful text content fetch with leading slash in path", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -293,54 +229,82 @@ func Test_GetFileContents(t *testing.T) { }, { name: "successful text content fetch with note when ref falls back to default branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"develop\"}"), + GetReposGitRefByOwnerByRepoByRef: func(w http.ResponseWriter, r *http.Request) { + path := strings.ReplaceAll(r.URL.Path, "%2F", "/") + switch { + case strings.Contains(path, "heads/main"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + case strings.Contains(path, "heads/develop"): w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "develop"}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Request for "refs/heads/main" -> 404 (doesn't exist) - // Request for "refs/heads/develop" (default branch) -> 200 - switch { - case strings.Contains(r.URL.Path, "heads/main"): - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - case strings.Contains(r.URL.Path, "heads/develop"): - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456"}}`)) - default: - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - } - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + } + }, + "GET /repos/{owner}/{repo}/git/refs/{ref}": func(w http.ResponseWriter, r *http.Request) { + path := strings.ReplaceAll(r.URL.Path, "%2F", "/") + switch { + case strings.Contains(path, "heads/main"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + case strings.Contains(path, "heads/develop"): w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoBySHAByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }), - ), - ), + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + } + }, + "GET /repos/{owner}/{repo}/git/refs/{ref:.*}": func(w http.ResponseWriter, r *http.Request) { + path := strings.ReplaceAll(r.URL.Path, "%2F", "/") + switch { + case strings.Contains(path, "heads/main"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + case strings.Contains(path, "heads/develop"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + } + }, + "GET /repos/owner/repo/git/ref/heads/main": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + "GET /repos/owner/repo/git/ref/heads/develop": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + }, + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + "GET /owner/repo/refs/heads/develop/README.md": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }, + "GET /owner/repo/refs%2Fheads%2Fdevelop/README.md": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }, + "GET /owner/repo/abc123def456/README.md": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -357,29 +321,17 @@ func Test_GetFileContents(t *testing.T) { }, { name: "content fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + GetRawReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -491,12 +443,9 @@ func Test_ForkRepository(t *testing.T) { }{ { name: "successful repository fork", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposForksByOwnerByRepo, - mockResponse(t, http.StatusAccepted, mockForkedRepo), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposForksByOwnerByRepo: mockResponse(t, http.StatusAccepted, mockForkedRepo), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -506,15 +455,12 @@ func Test_ForkRepository(t *testing.T) { }, { name: "repository fork fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposForksByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposForksByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -607,16 +553,11 @@ func Test_CreateBranch(t *testing.T) { }{ { name: "successful branch creation with from_branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockSourceRef, - ), - mock.WithRequestMatch( - mock.PostReposGitRefsByOwnerByRepo, - mockCreatedRef, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockSourceRef), + "GET /repos/owner/repo/git/ref/heads/main": mockResponse(t, http.StatusOK, mockSourceRef), + PostReposGitRefsByOwnerByRepo: mockResponse(t, http.StatusCreated, mockCreatedRef), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -628,25 +569,17 @@ func Test_CreateBranch(t *testing.T) { }, { name: "successful branch creation with default branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposByOwnerByRepo, - mockRepo, - ), - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockSourceRef, - ), - mock.WithRequestMatchHandler( - mock.PostReposGitRefsByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ - "ref": "refs/heads/new-feature", - "sha": "abc123def456", - }).andThen( - mockResponse(t, http.StatusCreated, mockCreatedRef), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, mockRepo), + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockSourceRef), + "GET /repos/owner/repo/git/ref/heads/main": mockResponse(t, http.StatusOK, mockSourceRef), + PostReposGitRefsByOwnerByRepo: expectRequestBody(t, map[string]interface{}{ + "ref": "refs/heads/new-feature", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusCreated, mockCreatedRef), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -657,15 +590,12 @@ func Test_CreateBranch(t *testing.T) { }, { name: "fail to get repository", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Repository not found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Repository not found"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "nonexistent-repo", @@ -676,15 +606,12 @@ func Test_CreateBranch(t *testing.T) { }, { name: "fail to get reference", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -696,19 +623,14 @@ func Test_CreateBranch(t *testing.T) { }, { name: "fail to create branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockSourceRef, - ), - mock.WithRequestMatchHandler( - mock.PostReposGitRefsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Reference already exists"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockSourceRef), + "GET /repos/owner/repo/git/ref/heads/main": mockResponse(t, http.StatusOK, mockSourceRef), + PostReposGitRefsByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Reference already exists"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -817,12 +739,9 @@ func Test_GetCommit(t *testing.T) { }{ { name: "successful commit fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepoByRef, - mockResponse(t, http.StatusOK, mockCommit), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -833,15 +752,12 @@ func Test_GetCommit(t *testing.T) { }, { name: "commit fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepoByRef: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -999,12 +915,9 @@ func Test_ListCommits(t *testing.T) { }{ { name: "successful commits fetch with default params", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposCommitsByOwnerByRepo, - mockCommits, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: mockResponse(t, http.StatusOK, mockCommits), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1014,19 +927,16 @@ func Test_ListCommits(t *testing.T) { }, { name: "successful commits fetch with branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "author": "username", - "sha": "main", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockCommits), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "author": "username", + "sha": "main", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1038,17 +948,14 @@ func Test_ListCommits(t *testing.T) { }, { name: "successful commits fetch with pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "page": "2", - "per_page": "10", - }).andThen( - mockResponse(t, http.StatusOK, mockCommits), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1060,15 +967,12 @@ func Test_ListCommits(t *testing.T) { }, { name: "commits fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "nonexistent-repo", @@ -1183,18 +1087,22 @@ func Test_CreateOrUpdateFile(t *testing.T) { }{ { name: "successful file creation", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Add example file", - "content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content - "branch": "main", - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Add example file", + "content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content + "branch": "main", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Add example file", + "content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content + "branch": "main", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1208,19 +1116,24 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "successful file update with SHA", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Update example file", - "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content - "branch": "main", - "sha": "abc123def456", - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Update example file", + "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content + "branch": "main", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Update example file", + "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content + "branch": "main", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1235,15 +1148,16 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "file creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) + }, + "PUT /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1257,35 +1171,42 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "sha validation - current sha matches (304 Not Modified)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/contents/docs/example.md", - Method: "HEAD", - }, - http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - // Verify If-None-Match header is set correctly - ifNoneMatch := req.Header.Get("If-None-Match") - if ifNoneMatch == `"abc123def456"` { - w.WriteHeader(http.StatusNotModified) - } else { - w.WriteHeader(http.StatusOK) - w.Header().Set("ETag", `"abc123def456"`) - } - }), - ), - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Update example file", - "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", - "branch": "main", - "sha": "abc123def456", - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, req *http.Request) { + ifNoneMatch := req.Header.Get("If-None-Match") + if ifNoneMatch == `"abc123def456"` { + w.WriteHeader(http.StatusNotModified) + } else { + w.WriteHeader(http.StatusOK) + w.Header().Set("ETag", `"abc123def456"`) + } + }, + "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, req *http.Request) { + ifNoneMatch := req.Header.Get("If-None-Match") + if ifNoneMatch == `"abc123def456"` { + w.WriteHeader(http.StatusNotModified) + } else { + w.WriteHeader(http.StatusOK) + w.Header().Set("ETag", `"abc123def456"`) + } + }, + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Update example file", + "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", + "branch": "main", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Update example file", + "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", + "branch": "main", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1300,19 +1221,16 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "sha validation - stale sha detected (200 OK with different ETag)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/contents/docs/example.md", - Method: "HEAD", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - // SHA doesn't match - return 200 with current ETag - w.Header().Set("ETag", `"newsha999888"`) - w.WriteHeader(http.StatusOK) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("ETag", `"newsha999888"`) + w.WriteHeader(http.StatusOK) + }, + "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("ETag", `"newsha999888"`) + w.WriteHeader(http.StatusOK) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1327,28 +1245,30 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "sha validation - file doesn't exist (404), proceed with create", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/contents/docs/example.md", - Method: "HEAD", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - }), - ), - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Create new file", - "content": "IyBOZXcgRmlsZQoKVGhpcyBpcyBhIG5ldyBmaWxlLg==", - "branch": "main", - "sha": "ignoredsha", // SHA is sent but GitHub API ignores it for new files - }).andThen( - mockResponse(t, http.StatusCreated, mockFileResponse), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Create new file", + "content": "IyBOZXcgRmlsZQoKVGhpcyBpcyBhIG5ldyBmaWxlLg==", + "branch": "main", + "sha": "ignoredsha", // SHA is sent but GitHub API ignores it for new files + }).andThen( + mockResponse(t, http.StatusCreated, mockFileResponse), + ), + "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Create new file", + "content": "IyBOZXcgRmlsZQoKVGhpcyBpcyBhIG5ldyBmaWxlLg==", + "branch": "main", + "sha": "ignoredsha", // SHA is sent but GitHub API ignores it for new files + }).andThen( + mockResponse(t, http.StatusCreated, mockFileResponse), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1363,29 +1283,32 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "no sha provided - file exists, returns warning", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/contents/docs/example.md", - Method: "HEAD", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("ETag", `"existing123"`) - w.WriteHeader(http.StatusOK) - }), - ), - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Update without SHA", - "content": "IyBVcGRhdGVkCgpVcGRhdGVkIHdpdGhvdXQgU0hBLg==", - "branch": "main", - "sha": "existing123", // SHA is automatically added from ETag - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("ETag", `"existing123"`) + w.WriteHeader(http.StatusOK) + }, + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Update without SHA", + "content": "IyBVcGRhdGVkCgpVcGRhdGVkIHdpdGhvdXQgU0hBLg==", + "branch": "main", + "sha": "existing123", // SHA is automatically added from ETag + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("ETag", `"existing123"`) + w.WriteHeader(http.StatusOK) + }, + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Update without SHA", + "content": "IyBVcGRhdGVkCgpVcGRhdGVkIHdpdGhvdXQgU0hBLg==", + "branch": "main", + "sha": "existing123", // SHA is automatically added from ETag + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1399,27 +1322,28 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "no sha provided - file doesn't exist, no warning", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/contents/docs/example.md", - Method: "HEAD", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - }), - ), - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Create new file", - "content": "IyBOZXcgRmlsZQoKQ3JlYXRlZCB3aXRob3V0IFNIQQ==", - "branch": "main", - }).andThen( - mockResponse(t, http.StatusCreated, mockFileResponse), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Create new file", + "content": "IyBOZXcgRmlsZQoKQ3JlYXRlZCB3aXRob3V0IFNIQQ==", + "branch": "main", + }).andThen( + mockResponse(t, http.StatusCreated, mockFileResponse), + ), + "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Create new file", + "content": "IyBOZXcgRmlsZQoKQ3JlYXRlZCB3aXRob3V0IFNIQQ==", + "branch": "main", + }).andThen( + mockResponse(t, http.StatusCreated, mockFileResponse), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1526,12 +1450,9 @@ func Test_CreateRepository(t *testing.T) { }{ { name: "successful repository creation with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/user/repos", - Method: "POST", - }, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + EndpointPattern("POST /user/repos"), expectRequestBody(t, map[string]interface{}{ "name": "test-repo", "description": "Test repository", @@ -1553,12 +1474,9 @@ func Test_CreateRepository(t *testing.T) { }, { name: "successful repository creation in organization", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/orgs/testorg/repos", - Method: "POST", - }, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + EndpointPattern("POST /orgs/testorg/repos"), expectRequestBody(t, map[string]interface{}{ "name": "test-repo", "description": "Test repository", @@ -1581,12 +1499,9 @@ func Test_CreateRepository(t *testing.T) { }, { name: "successful repository creation with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/user/repos", - Method: "POST", - }, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + EndpointPattern("POST /user/repos"), expectRequestBody(t, map[string]interface{}{ "name": "test-repo", "auto_init": false, @@ -1605,12 +1520,9 @@ func Test_CreateRepository(t *testing.T) { }, { name: "repository creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/user/repos", - Method: "POST", - }, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + EndpointPattern("POST /user/repos"), http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) _, _ = w.Write([]byte(`{"message": "Repository creation failed"}`)) @@ -1729,20 +1641,20 @@ func Test_PushFiles(t *testing.T) { }{ { name: "successful push of multiple files", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockCommit, ), // Create tree - mock.WithRequestMatchHandler( - mock.PostReposGitTreesByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitTreesByOwnerByRepo, expectRequestBody(t, map[string]interface{}{ "base_tree": "def456", "tree": []interface{}{ @@ -1764,8 +1676,8 @@ func Test_PushFiles(t *testing.T) { ), ), // Create commit - mock.WithRequestMatchHandler( - mock.PostReposGitCommitsByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitCommitsByOwnerByRepo, expectRequestBody(t, map[string]interface{}{ "message": "Update multiple files", "tree": "ghi789", @@ -1775,8 +1687,8 @@ func Test_PushFiles(t *testing.T) { ), ), // Update reference - mock.WithRequestMatchHandler( - mock.PatchReposGitRefsByOwnerByRepoByRef, + WithRequestMatchHandler( + PatchReposGitRefsByOwnerByRepoByRef, expectRequestBody(t, map[string]interface{}{ "sha": "jkl012", "force": false, @@ -1806,7 +1718,7 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails when files parameter is invalid", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // No requests expected ), requestArgs: map[string]interface{}{ @@ -1821,15 +1733,15 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails when files contains object without path", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockCommit, ), ), @@ -1849,15 +1761,15 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails when files contains object without content", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockCommit, ), ), @@ -1878,9 +1790,14 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails to get branch reference", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, + mockResponse(t, http.StatusNotFound, nil), + ), + // Mock Repositories.Get to fail when trying to create branch from default + WithRequestMatchHandler( + GetReposByOwnerByRepo, mockResponse(t, http.StatusNotFound, nil), ), ), @@ -1896,20 +1813,20 @@ func Test_PushFiles(t *testing.T) { }, "message": "Update file", }, - expectError: true, - expectedErrMsg: "failed to get branch reference", + expectError: false, + expectedErrMsg: "failed to create branch from default", }, { name: "fails to get base commit", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Fail to get commit - mock.WithRequestMatchHandler( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatchHandler( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockResponse(t, http.StatusNotFound, nil), ), ), @@ -1930,20 +1847,20 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails to create tree", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockCommit, ), // Fail to create tree - mock.WithRequestMatchHandler( - mock.PostReposGitTreesByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitTreesByOwnerByRepo, mockResponse(t, http.StatusInternalServerError, nil), ), ), @@ -1962,6 +1879,400 @@ func Test_PushFiles(t *testing.T) { expectError: true, expectedErrMsg: "failed to create tree", }, + { + name: "successful push to empty repository", + mockedClient: NewMockedHTTPClient( + // Get branch reference - first returns 409 for empty repo, second returns success after init + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, + func() http.HandlerFunc { + callCount := 0 + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + callCount++ + if callCount == 1 { + // First call: empty repo + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + } else { + // Second call: return the created reference + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(mockRef) + } + } + }(), + ), + // Mock Repositories.Get to return default branch for initialization + WithRequestMatch( + GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Create initial file using Contents API + WithRequestMatchHandler( + PutReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(t, err) + require.Equal(t, "Initial commit", body["message"]) + require.Equal(t, "main", body["branch"]) + w.WriteHeader(http.StatusCreated) + response := &github.RepositoryContentResponse{ + Commit: github.Commit{SHA: github.Ptr("abc123")}, + } + b, _ := json.Marshal(response) + _, _ = w.Write(b) + }), + ), + // Get the commit after initialization + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, + mockCommit, + ), + // Create tree + WithRequestMatch( + PostReposGitTreesByOwnerByRepo, + mockTree, + ), + // Create commit + WithRequestMatch( + PostReposGitCommitsByOwnerByRepo, + mockNewCommit, + ), + // Update reference + WithRequestMatch( + PatchReposGitRefsByOwnerByRepoByRef, + mockUpdatedRef, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# Initial README\n\nFirst commit to empty repository.", + }, + }, + "message": "Initial commit", + }, + expectError: false, + expectedRef: mockUpdatedRef, + }, + { + name: "successful push multiple files to empty repository", + mockedClient: NewMockedHTTPClient( + // Get branch reference - called twice: first for empty check, second after file creation + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, + func() http.HandlerFunc { + callCount := 0 + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + if callCount == 1 { + // First call: returns 409 Conflict for empty repo + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + } else { + // Second call: returns the updated reference after first file creation + w.WriteHeader(http.StatusOK) + b, _ := json.Marshal(&github.Reference{ + Ref: github.Ptr("refs/heads/main"), + Object: &github.GitObject{SHA: github.Ptr("init456")}, + }) + _, _ = w.Write(b) + } + }) + }(), + ), + // Mock Repositories.Get to return default branch for initialization + WithRequestMatch( + GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Create initial empty README.md file using Contents API to initialize repo + WithRequestMatchHandler( + PutReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(t, err) + require.Equal(t, "Initial commit", body["message"]) + require.Equal(t, "main", body["branch"]) + // Verify it's an empty file + expectedContent := base64.StdEncoding.EncodeToString([]byte("")) + require.Equal(t, expectedContent, body["content"]) + w.WriteHeader(http.StatusCreated) + response := &github.RepositoryContentResponse{ + Content: &github.RepositoryContent{ + SHA: github.Ptr("readme123"), + }, + Commit: github.Commit{ + SHA: github.Ptr("init456"), + Tree: &github.Tree{ + SHA: github.Ptr("tree456"), + }, + }, + } + b, _ := json.Marshal(response) + _, _ = w.Write(b) + }), + ), + // Get the commit to retrieve parent SHA + WithRequestMatchHandler( + GetReposGitCommitsByOwnerByRepoByCommitSHA, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + response := &github.Commit{ + SHA: github.Ptr("init456"), + Tree: &github.Tree{ + SHA: github.Ptr("tree456"), + }, + } + b, _ := json.Marshal(response) + _, _ = w.Write(b) + }), + ), + // Create tree with all user files + WithRequestMatchHandler( + PostReposGitTreesByOwnerByRepo, + expectRequestBody(t, map[string]interface{}{ + "base_tree": "tree456", + "tree": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "mode": "100644", + "type": "blob", + "content": "# Project\n\nProject README", + }, + map[string]interface{}{ + "path": ".gitignore", + "mode": "100644", + "type": "blob", + "content": "node_modules/\n*.log\n", + }, + map[string]interface{}{ + "path": "src/main.js", + "mode": "100644", + "type": "blob", + "content": "console.log('Hello World');\n", + }, + }, + }).andThen( + mockResponse(t, http.StatusCreated, mockTree), + ), + ), + // Create commit with all user files + WithRequestMatchHandler( + PostReposGitCommitsByOwnerByRepo, + expectRequestBody(t, map[string]interface{}{ + "message": "Initial project setup", + "tree": "ghi789", + "parents": []interface{}{"init456"}, + }).andThen( + mockResponse(t, http.StatusCreated, mockNewCommit), + ), + ), + // Update reference + WithRequestMatchHandler( + PatchReposGitRefsByOwnerByRepoByRef, + expectRequestBody(t, map[string]interface{}{ + "sha": "jkl012", + "force": false, + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedRef), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# Project\n\nProject README", + }, + map[string]interface{}{ + "path": ".gitignore", + "content": "node_modules/\n*.log\n", + }, + map[string]interface{}{ + "path": "src/main.js", + "content": "console.log('Hello World');\n", + }, + }, + "message": "Initial project setup", + }, + expectError: false, + expectedRef: mockUpdatedRef, + }, + { + name: "fails to create initial file in empty repository", + mockedClient: NewMockedHTTPClient( + // Get branch reference returns 409 Conflict for empty repo + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + }), + ), + // Mock Repositories.Get to return default branch + WithRequestMatch( + GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Fail to create initial file using Contents API + WithRequestMatchHandler( + PutReposContentsByOwnerByRepoByPath, + mockResponse(t, http.StatusInternalServerError, nil), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# README", + }, + }, + "message": "Initial commit", + }, + expectError: false, + expectedErrMsg: "failed to initialize repository", + }, + { + name: "fails to get reference after creating initial file in empty repository", + mockedClient: NewMockedHTTPClient( + // Get branch reference - called twice + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, + func() http.HandlerFunc { + callCount := 0 + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + if callCount == 1 { + // First call: returns 409 Conflict for empty repo + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + } else { + // Second call: fails + w.WriteHeader(http.StatusInternalServerError) + } + }) + }(), + ), + // Mock Repositories.Get to return default branch + WithRequestMatch( + GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Create initial file using Contents API + WithRequestMatch( + PutReposContentsByOwnerByRepoByPath, + &github.RepositoryContentResponse{ + Content: &github.RepositoryContent{SHA: github.Ptr("readme123")}, + Commit: github.Commit{SHA: github.Ptr("init456")}, + }, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# README", + }, + }, + "message": "Initial commit", + }, + expectError: false, + expectedErrMsg: "failed to initialize repository", + }, + { + name: "fails to get commit in empty repository with multiple files", + mockedClient: NewMockedHTTPClient( + // Get branch reference returns 409 Conflict for empty repo + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + }), + ), + // Mock Repositories.Get to return default branch + WithRequestMatch( + GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Create initial file using Contents API + WithRequestMatch( + PutReposContentsByOwnerByRepoByPath, + &github.RepositoryContentResponse{ + Content: &github.RepositoryContent{SHA: github.Ptr("readme123")}, + Commit: github.Commit{SHA: github.Ptr("init456")}, + }, + ), + // Fail to get commit + WithRequestMatchHandler( + GetReposGitCommitsByOwnerByRepoByCommitSHA, + mockResponse(t, http.StatusInternalServerError, nil), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# README", + }, + map[string]interface{}{ + "path": "LICENSE", + "content": "MIT", + }, + }, + "message": "Initial commit", + }, + expectError: false, + expectedErrMsg: "failed to initialize repository", + }, } for _, tc := range tests { @@ -2046,7 +2357,7 @@ func Test_ListBranches(t *testing.T) { tests := []struct { name string args map[string]interface{} - mockResponses []mock.MockBackendOption + mockResponses []MockBackendOption wantErr bool errContains string }{ @@ -2057,9 +2368,9 @@ func Test_ListBranches(t *testing.T) { "repo": "repo", "page": float64(2), }, - mockResponses: []mock.MockBackendOption{ - mock.WithRequestMatch( - mock.GetReposBranchesByOwnerByRepo, + mockResponses: []MockBackendOption{ + WithRequestMatch( + GetReposBranchesByOwnerByRepo, mockBranches, ), }, @@ -2070,7 +2381,7 @@ func Test_ListBranches(t *testing.T) { args: map[string]interface{}{ "repo": "repo", }, - mockResponses: []mock.MockBackendOption{}, + mockResponses: []MockBackendOption{}, wantErr: false, errContains: "missing required parameter: owner", }, @@ -2079,7 +2390,7 @@ func Test_ListBranches(t *testing.T) { args: map[string]interface{}{ "owner": "owner", }, - mockResponses: []mock.MockBackendOption{}, + mockResponses: []MockBackendOption{}, wantErr: false, errContains: "missing required parameter: repo", }, @@ -2088,7 +2399,7 @@ func Test_ListBranches(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create mock client - mockClient := github.NewClient(mock.NewMockedHTTPClient(tt.mockResponses...)) + mockClient := github.NewClient(NewMockedHTTPClient(tt.mockResponses...)) deps := BaseDeps{ Client: mockClient, } @@ -2185,20 +2496,20 @@ func Test_DeleteFile(t *testing.T) { }{ { name: "successful file deletion using Git Data API", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockCommit, ), // Create tree - mock.WithRequestMatchHandler( - mock.PostReposGitTreesByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitTreesByOwnerByRepo, expectRequestBody(t, map[string]interface{}{ "base_tree": "def456", "tree": []interface{}{ @@ -2214,8 +2525,8 @@ func Test_DeleteFile(t *testing.T) { ), ), // Create commit - mock.WithRequestMatchHandler( - mock.PostReposGitCommitsByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitCommitsByOwnerByRepo, expectRequestBody(t, map[string]interface{}{ "message": "Delete example file", "tree": "ghi789", @@ -2225,8 +2536,8 @@ func Test_DeleteFile(t *testing.T) { ), ), // Update reference - mock.WithRequestMatchHandler( - mock.PatchReposGitRefsByOwnerByRepoByRef, + WithRequestMatchHandler( + PatchReposGitRefsByOwnerByRepoByRef, expectRequestBody(t, map[string]interface{}{ "sha": "jkl012", "force": false, @@ -2252,9 +2563,9 @@ func Test_DeleteFile(t *testing.T) { }, { name: "file deletion fails - branch not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) @@ -2362,9 +2673,9 @@ func Test_ListTags(t *testing.T) { }{ { name: "successful tags list", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposTagsByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposTagsByOwnerByRepo, expectPath( t, "/repos/owner/repo/tags", @@ -2382,9 +2693,9 @@ func Test_ListTags(t *testing.T) { }, { name: "list tags fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposTagsByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposTagsByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) @@ -2488,9 +2799,9 @@ func Test_GetTag(t *testing.T) { }{ { name: "successful tag retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, expectPath( t, "/repos/owner/repo/git/ref/tags/v1.0.0", @@ -2498,8 +2809,8 @@ func Test_GetTag(t *testing.T) { mockResponse(t, http.StatusOK, mockTagRef), ), ), - mock.WithRequestMatchHandler( - mock.GetReposGitTagsByOwnerByRepoByTagSha, + WithRequestMatchHandler( + GetReposGitTagsByOwnerByRepoByTagSHA, expectPath( t, "/repos/owner/repo/git/tags/v1.0.0-tag-sha", @@ -2518,9 +2829,9 @@ func Test_GetTag(t *testing.T) { }, { name: "tag reference not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Reference does not exist"}`)) @@ -2537,13 +2848,13 @@ func Test_GetTag(t *testing.T) { }, { name: "tag object not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + mockedClient: NewMockedHTTPClient( + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockTagRef, ), - mock.WithRequestMatchHandler( - mock.GetReposGitTagsByOwnerByRepoByTagSha, + WithRequestMatchHandler( + GetReposGitTagsByOwnerByRepoByTagSHA, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Tag object does not exist"}`)) @@ -2641,9 +2952,9 @@ func Test_ListReleases(t *testing.T) { }{ { name: "successful releases list", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposReleasesByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatch( + GetReposReleasesByOwnerByRepo, mockReleases, ), ), @@ -2656,9 +2967,9 @@ func Test_ListReleases(t *testing.T) { }, { name: "releases list fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposReleasesByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -2732,9 +3043,9 @@ func Test_GetLatestRelease(t *testing.T) { }{ { name: "successful latest release fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposReleasesLatestByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatch( + GetReposReleasesLatestByOwnerByRepo, mockRelease, ), ), @@ -2747,9 +3058,9 @@ func Test_GetLatestRelease(t *testing.T) { }, { name: "latest release fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesLatestByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposReleasesLatestByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -2829,9 +3140,9 @@ func Test_GetReleaseByTag(t *testing.T) { }{ { name: "successful release by tag fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposReleasesTagsByOwnerByRepoByTag, + mockedClient: NewMockedHTTPClient( + WithRequestMatch( + GetReposReleasesTagsByOwnerByRepoByTag, mockRelease, ), ), @@ -2845,7 +3156,7 @@ func Test_GetReleaseByTag(t *testing.T) { }, { name: "missing owner parameter", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "repo": "repo", "tag": "v1.0.0", @@ -2855,7 +3166,7 @@ func Test_GetReleaseByTag(t *testing.T) { }, { name: "missing repo parameter", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "owner": "owner", "tag": "v1.0.0", @@ -2865,7 +3176,7 @@ func Test_GetReleaseByTag(t *testing.T) { }, { name: "missing tag parameter", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -2875,9 +3186,9 @@ func Test_GetReleaseByTag(t *testing.T) { }, { name: "release by tag not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesTagsByOwnerByRepoByTag, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposReleasesTagsByOwnerByRepoByTag, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -2894,9 +3205,9 @@ func Test_GetReleaseByTag(t *testing.T) { }, { name: "server error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesTagsByOwnerByRepoByTag, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposReleasesTagsByOwnerByRepoByTag, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) @@ -3143,7 +3454,7 @@ func Test_resolveGitReference(t *testing.T) { sha: "123sha456", mockSetup: func() *http.Client { // No API calls should be made when SHA is provided - return mock.NewMockedHTTPClient() + return NewMockedHTTPClient() }, expectedOutput: &raw.ContentOpts{ SHA: "123sha456", @@ -3155,16 +3466,16 @@ func Test_resolveGitReference(t *testing.T) { ref: "", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) }), ), - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "/git/ref/heads/main") w.WriteHeader(http.StatusOK) @@ -3184,9 +3495,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "refs/heads/feature-branch", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch") w.WriteHeader(http.StatusOK) @@ -3206,9 +3517,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "main", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/git/ref/heads/main") { w.WriteHeader(http.StatusOK) @@ -3232,9 +3543,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "v1.0.0", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.Contains(r.URL.Path, "/git/ref/heads/v1.0.0"): @@ -3262,9 +3573,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "heads/feature-branch", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch") w.WriteHeader(http.StatusOK) @@ -3284,9 +3595,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "tags/v1.0.0", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "/git/ref/tags/v1.0.0") w.WriteHeader(http.StatusOK) @@ -3306,9 +3617,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "nonexistent", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { // Both branch and tag attempts should return 404 w.WriteHeader(http.StatusNotFound) @@ -3325,9 +3636,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "refs/pull/123/head", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "/git/ref/pull/123/head") w.WriteHeader(http.StatusOK) @@ -3348,7 +3659,7 @@ func Test_resolveGitReference(t *testing.T) { sha: "", mockSetup: func() *http.Client { // No API calls should be made when ref looks like SHA - return mock.NewMockedHTTPClient() + return NewMockedHTTPClient() }, expectedOutput: &raw.ContentOpts{ SHA: "abc123def456abc123def456abc123def456abc1", @@ -3456,12 +3767,12 @@ func Test_ListStarredRepositories(t *testing.T) { }{ { name: "successful list for authenticated user", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetUserStarred, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetUserStarred, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(mockStarredRepos)) + _, _ = w.Write(MustMarshal(mockStarredRepos)) }), ), ), @@ -3471,12 +3782,12 @@ func Test_ListStarredRepositories(t *testing.T) { }, { name: "successful list for specific user", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetUsersStarredByUsername, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetUsersStarredByUsername, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(mockStarredRepos)) + _, _ = w.Write(MustMarshal(mockStarredRepos)) }), ), ), @@ -3488,9 +3799,9 @@ func Test_ListStarredRepositories(t *testing.T) { }, { name: "list fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetUserStarred, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetUserStarred, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -3570,9 +3881,9 @@ func Test_StarRepository(t *testing.T) { }{ { name: "successful star", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutUserStarredByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + PutUserStarredByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }), @@ -3586,9 +3897,9 @@ func Test_StarRepository(t *testing.T) { }, { name: "star fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutUserStarredByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + PutUserStarredByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -3661,9 +3972,9 @@ func Test_UnstarRepository(t *testing.T) { }{ { name: "successful unstar", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteUserStarredByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + DeleteUserStarredByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }), @@ -3677,9 +3988,9 @@ func Test_UnstarRepository(t *testing.T) { }, { name: "unstar fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteUserStarredByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + DeleteUserStarredByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index ee43e9d04..28ce63b46 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -102,15 +102,16 @@ func GetRepositoryResourcePrContent(t translations.TranslationHelperFunc) invent // repositoryResourceContentsHandlerFunc returns a ResourceHandlerFunc that creates handlers on-demand. func repositoryResourceContentsHandlerFunc(resourceURITemplate *uritemplate.Template) inventory.ResourceHandlerFunc { - return func(deps any) mcp.ResourceHandler { - d := deps.(ToolDependencies) - return RepositoryResourceContentsHandler(d, resourceURITemplate) + return func(_ any) mcp.ResourceHandler { + return RepositoryResourceContentsHandler(resourceURITemplate) } } // RepositoryResourceContentsHandler returns a handler function for repository content requests. -func RepositoryResourceContentsHandler(deps ToolDependencies, resourceURITemplate *uritemplate.Template) mcp.ResourceHandler { +// It retrieves ToolDependencies from the context at call time via MustDepsFromContext. +func RepositoryResourceContentsHandler(resourceURITemplate *uritemplate.Template) mcp.ResourceHandler { return func(ctx context.Context, request *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + deps := MustDepsFromContext(ctx) // Match the URI to extract parameters uriValues := resourceURITemplate.Match(request.Params.URI) if uriValues == nil { diff --git a/pkg/github/repository_resource_completions.go b/pkg/github/repository_resource_completions.go index aeb2d88a6..c70cfe948 100644 --- a/pkg/github/repository_resource_completions.go +++ b/pkg/github/repository_resource_completions.go @@ -33,8 +33,10 @@ func RepositoryResourceCompletionHandler(getClient GetClientFn) func(ctx context argName := req.Params.Argument.Name argValue := req.Params.Argument.Value - resolved := req.Params.Context.Arguments - if resolved == nil { + var resolved map[string]string + if req.Params.Context != nil && req.Params.Context.Arguments != nil { + resolved = req.Params.Context.Arguments + } else { resolved = map[string]string{} } diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go index b55b821af..a3b3ca754 100644 --- a/pkg/github/repository_resource_test.go +++ b/pkg/github/repository_resource_test.go @@ -26,7 +26,7 @@ func Test_repositoryResourceContents(t *testing.T) { name string mockedClient *http.Client uri string - handlerFn func(deps ToolDependencies) mcp.ResourceHandler + handlerFn func() mcp.ResourceHandler expectedResponseType resourceResponseType expectError string expectedResult *mcp.ReadResourceResult @@ -41,8 +41,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo:///repo/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "owner is required", @@ -57,8 +57,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner//refs/heads/main/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceBranchContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceBranchContentURITemplate) }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "repo is required", @@ -73,8 +73,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/contents/data.png", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeBlob, expectedResult: &mcp.ReadResourceResult{ @@ -94,8 +94,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -117,8 +117,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/contents/pkg/github/actions.go", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -138,8 +138,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/refs/heads/main/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceBranchContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceBranchContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -159,8 +159,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/refs/tags/v1.0.0/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceTagContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceTagContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -180,8 +180,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/sha/abc123/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceCommitContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceCommitContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -206,8 +206,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/refs/pull/42/head/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourcePrContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourcePrContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -226,8 +226,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/contents/nonexistent.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "404 Not Found", @@ -242,7 +242,8 @@ func Test_repositoryResourceContents(t *testing.T) { Client: client, RawClient: mockRawClient, } - handler := tc.handlerFn(deps) + ctx := ContextWithDeps(context.Background(), deps) + handler := tc.handlerFn() request := &mcp.ReadResourceRequest{ Params: &mcp.ReadResourceParams{ @@ -250,7 +251,7 @@ func Test_repositoryResourceContents(t *testing.T) { }, } - resp, err := handler(context.TODO(), request) + resp, err := handler(ctx, request) if tc.expectError != "" { require.ErrorContains(t, err, tc.expectError) diff --git a/pkg/github/scope_filter.go b/pkg/github/scope_filter.go new file mode 100644 index 000000000..42f8e98b0 --- /dev/null +++ b/pkg/github/scope_filter.go @@ -0,0 +1,64 @@ +package github + +import ( + "context" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" +) + +// repoScopesSet contains scopes that grant access to repository content. +// Tools requiring only these scopes work on public repos without any token scope, +// so we don't filter them out even if the token lacks repo/public_repo. +var repoScopesSet = map[string]bool{ + string(scopes.Repo): true, + string(scopes.PublicRepo): true, +} + +// onlyRequiresRepoScopes returns true if all of the tool's accepted scopes +// are repo-related scopes (repo, public_repo). Such tools work on public +// repositories without needing any scope. +func onlyRequiresRepoScopes(acceptedScopes []string) bool { + if len(acceptedScopes) == 0 { + return false + } + for _, scope := range acceptedScopes { + if !repoScopesSet[scope] { + return false + } + } + return true +} + +// CreateToolScopeFilter creates an inventory.ToolFilter that filters tools +// based on the token's OAuth scopes. +// +// For PATs (Personal Access Tokens), we cannot issue OAuth scope challenges +// like we can with OAuth apps. Instead, we hide tools that require scopes +// the token doesn't have. +// +// This is the recommended way to filter tools for stdio servers where the +// token is known at startup and won't change during the session. +// +// The filter returns true (include tool) if: +// - The tool has no scope requirements (AcceptedScopes is empty) +// - The tool is read-only and only requires repo/public_repo scopes (works on public repos) +// - The token has at least one of the tool's accepted scopes +// +// Example usage: +// +// tokenScopes, err := scopes.FetchTokenScopes(ctx, token) +// if err != nil { +// // Handle error - maybe skip filtering +// } +// filter := github.CreateToolScopeFilter(tokenScopes) +// inventory := github.NewInventory(t).WithFilter(filter).Build() +func CreateToolScopeFilter(tokenScopes []string) inventory.ToolFilter { + return func(_ context.Context, tool *inventory.ServerTool) (bool, error) { + // Read-only tools requiring only repo/public_repo work on public repos without any scope + if tool.Tool.Annotations != nil && tool.Tool.Annotations.ReadOnlyHint && onlyRequiresRepoScopes(tool.AcceptedScopes) { + return true, nil + } + return scopes.HasRequiredScopes(tokenScopes, tool.AcceptedScopes), nil + } +} diff --git a/pkg/github/scope_filter_test.go b/pkg/github/scope_filter_test.go new file mode 100644 index 000000000..9cdd4db19 --- /dev/null +++ b/pkg/github/scope_filter_test.go @@ -0,0 +1,191 @@ +package github + +import ( + "context" + "testing" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateToolScopeFilter(t *testing.T) { + // Create test tools with various scope requirements + toolNoScopes := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "no_scopes_tool"}, + AcceptedScopes: nil, + } + + toolEmptyScopes := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "empty_scopes_tool"}, + AcceptedScopes: []string{}, + } + + toolRepoScope := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "repo_tool"}, + AcceptedScopes: []string{"repo"}, + } + + toolRepoScopeReadOnly := &inventory.ServerTool{ + Tool: mcp.Tool{ + Name: "repo_tool_readonly", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, + }, + AcceptedScopes: []string{"repo"}, + } + + toolPublicRepoScope := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "public_repo_tool"}, + AcceptedScopes: []string{"public_repo", "repo"}, // repo is parent, also accepted + } + + toolPublicRepoScopeReadOnly := &inventory.ServerTool{ + Tool: mcp.Tool{ + Name: "public_repo_tool_readonly", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, + }, + AcceptedScopes: []string{"public_repo", "repo"}, + } + + toolGistScope := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "gist_tool"}, + AcceptedScopes: []string{"gist"}, + } + + toolMultiScope := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "multi_scope_tool"}, + AcceptedScopes: []string{"repo", "admin:org"}, + } + + tests := []struct { + name string + tokenScopes []string + tool *inventory.ServerTool + expected bool + }{ + { + name: "tool with no scopes is always visible", + tokenScopes: []string{}, + tool: toolNoScopes, + expected: true, + }, + { + name: "tool with empty scopes is always visible", + tokenScopes: []string{"repo"}, + tool: toolEmptyScopes, + expected: true, + }, + { + name: "token with exact scope can see tool", + tokenScopes: []string{"repo"}, + tool: toolRepoScope, + expected: true, + }, + { + name: "token with parent scope can see child-scoped tool", + tokenScopes: []string{"repo"}, + tool: toolPublicRepoScope, + expected: true, + }, + { + name: "token missing required scope cannot see tool", + tokenScopes: []string{"gist"}, + tool: toolRepoScope, + expected: false, + }, + { + name: "token with unrelated scope cannot see tool", + tokenScopes: []string{"repo"}, + tool: toolGistScope, + expected: false, + }, + { + name: "token with one of multiple accepted scopes can see tool", + tokenScopes: []string{"admin:org"}, + tool: toolMultiScope, + expected: true, + }, + { + name: "empty token scopes cannot see scoped tools", + tokenScopes: []string{}, + tool: toolRepoScope, + expected: false, + }, + { + name: "empty token scopes CAN see read-only repo tools (public repos)", + tokenScopes: []string{}, + tool: toolRepoScopeReadOnly, + expected: true, + }, + { + name: "empty token scopes CAN see read-only public_repo tools", + tokenScopes: []string{}, + tool: toolPublicRepoScopeReadOnly, + expected: true, + }, + { + name: "token with multiple scopes where one matches", + tokenScopes: []string{"gist", "repo"}, + tool: toolPublicRepoScope, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := CreateToolScopeFilter(tt.tokenScopes) + result, err := filter(context.Background(), tt.tool) + + require.NoError(t, err) + assert.Equal(t, tt.expected, result, "filter result should match expected") + }) + } +} + +func TestCreateToolScopeFilter_Integration(t *testing.T) { + // Test integration with inventory builder + tools := []inventory.ServerTool{ + { + Tool: mcp.Tool{Name: "public_tool"}, + Toolset: inventory.ToolsetMetadata{ID: "test"}, + AcceptedScopes: nil, // No scopes required + }, + { + Tool: mcp.Tool{Name: "repo_tool"}, + Toolset: inventory.ToolsetMetadata{ID: "test"}, + AcceptedScopes: []string{"repo"}, + }, + { + Tool: mcp.Tool{Name: "gist_tool"}, + Toolset: inventory.ToolsetMetadata{ID: "test"}, + AcceptedScopes: []string{"gist"}, + }, + } + + // Create filter for token with only "repo" scope + filter := CreateToolScopeFilter([]string{"repo"}) + + // Build inventory with the filter + inv, err := inventory.NewBuilder(). + SetTools(tools). + WithToolsets([]string{"test"}). + WithFilter(filter). + Build() + require.NoError(t, err) + + // Get available tools + availableTools := inv.AvailableTools(context.Background()) + + // Should see public_tool and repo_tool, but not gist_tool + assert.Len(t, availableTools, 2) + + toolNames := make([]string, len(availableTools)) + for i, tool := range availableTools { + toolNames[i] = tool.Tool.Name + } + + assert.Contains(t, toolNames, "public_tool") + assert.Contains(t, toolNames, "repo_tool") + assert.NotContains(t, toolNames, "gist_tool") +} diff --git a/pkg/github/search.go b/pkg/github/search.go index 9a8b971e2..552fbfe78 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -9,6 +9,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -56,6 +57,7 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { query, err := RequiredParam[string](args, "query") if err != nil { @@ -198,6 +200,7 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { query, err := RequiredParam[string](args, "query") if err != nil { @@ -379,6 +382,7 @@ func SearchUsers(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { return userOrOrgHandler(ctx, "user", deps, args) }, @@ -420,6 +424,7 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, + []scopes.Scope{scopes.ReadOrg}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { return userOrOrgHandler(ctx, "org", deps, args) }, diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index be1b26714..e15758c3e 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -10,7 +10,6 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -67,20 +66,17 @@ func Test_SearchRepositories(t *testing.T) { }{ { name: "successful repository search", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchRepositories, - expectQueryParams(t, map[string]string{ - "q": "golang test", - "sort": "stars", - "order": "desc", - "page": "2", - "per_page": "10", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchRepositories: expectQueryParams(t, map[string]string{ + "q": "golang test", + "sort": "stars", + "order": "desc", + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "golang test", "sort": "stars", @@ -93,18 +89,15 @@ func Test_SearchRepositories(t *testing.T) { }, { name: "repository search with default pagination", - 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), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchRepositories: expectQueryParams(t, map[string]string{ + "q": "golang test", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "golang test", }, @@ -113,15 +106,12 @@ func Test_SearchRepositories(t *testing.T) { }, { name: "search fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchRepositories, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Invalid query"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchRepositories: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Invalid query"}`)) + }), + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, @@ -194,18 +184,15 @@ func Test_SearchRepositories_FullOutput(t *testing.T) { }, } - 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), - ), + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchRepositories: expectQueryParams(t, map[string]string{ + "q": "golang test", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ) + }) client := github.NewClient(mockedClient) serverTool := SearchRepositories(translations.NullTranslationHelper) @@ -291,20 +278,17 @@ func Test_SearchCode(t *testing.T) { }{ { name: "successful code search with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchCode, - expectQueryParams(t, map[string]string{ - "q": "fmt.Println language:go", - "sort": "indexed", - "order": "desc", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCode: expectQueryParams(t, map[string]string{ + "q": "fmt.Println language:go", + "sort": "indexed", + "order": "desc", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "fmt.Println language:go", "sort": "indexed", @@ -317,18 +301,15 @@ func Test_SearchCode(t *testing.T) { }, { name: "code search with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchCode, - expectQueryParams(t, map[string]string{ - "q": "fmt.Println language:go", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCode: expectQueryParams(t, map[string]string{ + "q": "fmt.Println language:go", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "fmt.Println language:go", }, @@ -337,15 +318,12 @@ func Test_SearchCode(t *testing.T) { }, { name: "search code fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchCode, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCode: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, @@ -451,20 +429,17 @@ func Test_SearchUsers(t *testing.T) { }{ { name: "successful users search with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:user location:finland language:go", - "sort": "followers", - "order": "desc", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:user location:finland language:go", + "sort": "followers", + "order": "desc", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "location:finland language:go", "sort": "followers", @@ -477,18 +452,15 @@ func Test_SearchUsers(t *testing.T) { }, { name: "users search with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:user location:finland language:go", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:user location:finland language:go", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "location:finland language:go", }, @@ -497,18 +469,15 @@ func Test_SearchUsers(t *testing.T) { }, { name: "query with existing type:user filter - no duplication", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:user location:seattle followers:>100", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:user location:seattle followers:>100", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "type:user location:seattle followers:>100", }, @@ -517,18 +486,15 @@ func Test_SearchUsers(t *testing.T) { }, { name: "complex query with existing type:user filter and OR operators", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:user (location:seattle OR location:california) followers:>50", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:user (location:seattle OR location:california) followers:>50", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "type:user (location:seattle OR location:california) followers:>50", }, @@ -537,15 +503,12 @@ func Test_SearchUsers(t *testing.T) { }, { name: "search users fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, @@ -652,18 +615,15 @@ func Test_SearchOrgs(t *testing.T) { }{ { name: "successful org search", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:org github", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:org github", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "github", }, @@ -672,18 +632,15 @@ func Test_SearchOrgs(t *testing.T) { }, { name: "query with existing type:org filter - no duplication", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:org location:california followers:>1000", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:org location:california followers:>1000", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "type:org location:california followers:>1000", }, @@ -692,18 +649,15 @@ func Test_SearchOrgs(t *testing.T) { }, { name: "complex query with existing type:org filter and OR operators", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", }, @@ -712,15 +666,12 @@ func Test_SearchOrgs(t *testing.T) { }, { name: "org search fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index 0de5166ba..fa60021e5 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -9,6 +9,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -45,6 +46,7 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv Required: []string{"owner", "repo", "alertNumber"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -131,6 +133,7 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index f898de61d..7bdb978cd 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -9,6 +9,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -83,6 +84,7 @@ func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) inventor }, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -246,6 +248,7 @@ func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inve Required: []string{"owner", "repo"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -330,6 +333,7 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S Required: []string{"ghsaId"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -401,6 +405,7 @@ func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) i Required: []string{"org"}, }, }, + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { org, err := RequiredParam[string](args, "org") if err != nil { diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index a59cd9a93..f4ae5f831 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -51,10 +51,11 @@ func (s stubDeps) GetRawClient(ctx context.Context) (*raw.Client, error) { return nil, nil } -func (s stubDeps) GetRepoAccessCache() *lockdown.RepoAccessCache { return s.repoAccessCache } -func (s stubDeps) GetT() translations.TranslationHelperFunc { return s.t } -func (s stubDeps) GetFlags() FeatureFlags { return s.flags } -func (s stubDeps) GetContentWindowSize() int { return s.contentWindowSize } +func (s stubDeps) GetRepoAccessCache() *lockdown.RepoAccessCache { return s.repoAccessCache } +func (s stubDeps) GetT() translations.TranslationHelperFunc { return s.t } +func (s stubDeps) GetFlags() FeatureFlags { return s.flags } +func (s stubDeps) GetContentWindowSize() int { return s.contentWindowSize } +func (s stubDeps) IsFeatureEnabled(_ context.Context, _ string) bool { return false } // Helper functions to create stub client functions for error testing func stubClientFnFromHTTP(httpClient *http.Client) func(context.Context) (*github.Client, error) { @@ -83,6 +84,7 @@ func stubRepoAccessCache(client *githubv4.Client, ttl time.Duration) *lockdown.R func stubFeatureFlags(enabledFlags map[string]bool) FeatureFlags { return FeatureFlags{ LockdownMode: enabledFlags["lockdown-mode"], + InsidersMode: enabledFlags["insiders-mode"], } } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 4d7d74717..4d6abd4a7 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -28,10 +28,11 @@ var ( Icon: "check-circle", } ToolsetMetadataContext = inventory.ToolsetMetadata{ - ID: "context", - Description: "Tools that provide context about the current user and GitHub context you are operating in", - Default: true, - Icon: "person", + ID: "context", + Description: "Tools that provide context about the current user and GitHub context you are operating in", + Default: true, + Icon: "person", + InstructionsFunc: generateContextToolsetInstructions, } ToolsetMetadataRepos = inventory.ToolsetMetadata{ ID: "repos", @@ -45,16 +46,18 @@ var ( Icon: "git-branch", } ToolsetMetadataIssues = inventory.ToolsetMetadata{ - ID: "issues", - Description: "GitHub Issues related tools", - Default: true, - Icon: "issue-opened", + ID: "issues", + Description: "GitHub Issues related tools", + Default: true, + Icon: "issue-opened", + InstructionsFunc: generateIssuesToolsetInstructions, } ToolsetMetadataPullRequests = inventory.ToolsetMetadata{ - ID: "pull_requests", - Description: "GitHub Pull Request related tools", - Default: true, - Icon: "git-pull-request", + ID: "pull_requests", + Description: "GitHub Pull Request related tools", + Default: true, + Icon: "git-pull-request", + InstructionsFunc: generatePullRequestsToolsetInstructions, } ToolsetMetadataUsers = inventory.ToolsetMetadata{ ID: "users", @@ -93,9 +96,10 @@ var ( Icon: "bell", } ToolsetMetadataDiscussions = inventory.ToolsetMetadata{ - ID: "discussions", - Description: "GitHub Discussions related tools", - Icon: "comment-discussion", + ID: "discussions", + Description: "GitHub Discussions related tools", + Icon: "comment-discussion", + InstructionsFunc: generateDiscussionsToolsetInstructions, } ToolsetMetadataGists = inventory.ToolsetMetadata{ ID: "gists", @@ -108,9 +112,10 @@ var ( Icon: "shield", } ToolsetMetadataProjects = inventory.ToolsetMetadata{ - ID: "projects", - Description: "GitHub Projects related tools", - Icon: "project", + ID: "projects", + Description: "GitHub Projects related tools", + Icon: "project", + InstructionsFunc: generateProjectsToolsetInstructions, } ToolsetMetadataStargazers = inventory.ToolsetMetadata{ ID: "stargazers", @@ -264,6 +269,11 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { DeleteProjectItem(t), UpdateProjectItem(t), + // Consolidated project tools (enabled via feature flag) + ProjectsList(t), + ProjectsGet(t), + ProjectsWrite(t), + // Label tools GetLabel(t), GetLabelForLabelsToolset(t), @@ -289,7 +299,8 @@ func ToStringPtr(s string) *string { // GenerateToolsetsHelp generates the help text for the toolsets flag func GenerateToolsetsHelp() string { // Get toolset group to derive defaults and available toolsets - r := NewInventory(stubTranslator).Build() + // Build() can only fail if WithTools specifies invalid tools - not used here + r, _ := NewInventory(stubTranslator).Build() // Format default tools from metadata using strings.Builder var defaultBuf strings.Builder @@ -371,7 +382,8 @@ func AddDefaultToolset(result []string) []string { result = RemoveToolset(result, string(ToolsetMetadataDefault.ID)) // Get default toolset IDs from the Inventory - r := NewInventory(stubTranslator).Build() + // Build() can only fail if WithTools specifies invalid tools - not used here + r, _ := NewInventory(stubTranslator).Build() for _, id := range r.DefaultToolsetIDs() { if !seen[string(id)] { result = append(result, string(id)) @@ -423,7 +435,8 @@ func CleanTools(toolNames []string) []string { // GetDefaultToolsetIDs returns the IDs of toolsets marked as Default. // This is a convenience function that builds an inventory to determine defaults. func GetDefaultToolsetIDs() []string { - r := NewInventory(stubTranslator).Build() + // Build() can only fail if WithTools specifies invalid tools - not used here + r, _ := NewInventory(stubTranslator).Build() ids := r.DefaultToolsetIDs() result := make([]string, len(ids)) for i, id := range ids { diff --git a/pkg/github/toolset_icons_test.go b/pkg/github/toolset_icons_test.go index fd9cec462..7cfe4bef7 100644 --- a/pkg/github/toolset_icons_test.go +++ b/pkg/github/toolset_icons_test.go @@ -13,7 +13,8 @@ import ( // This prevents broken icon references from being merged. func TestAllToolsetIconsExist(t *testing.T) { // Get all available toolsets from the inventory - inv := NewInventory(stubTranslator).Build() + inv, err := NewInventory(stubTranslator).Build() + require.NoError(t, err) toolsets := inv.AvailableToolsets() // Also test remote-only toolsets @@ -72,7 +73,8 @@ func TestToolsetMetadataHasIcons(t *testing.T) { "default": true, // Meta-toolset } - inv := NewInventory(stubTranslator).Build() + inv, err := NewInventory(stubTranslator).Build() + require.NoError(t, err) toolsets := inv.AvailableToolsets() for _, ts := range toolsets { diff --git a/pkg/github/instructions.go b/pkg/github/toolset_instructions.go similarity index 65% rename from pkg/github/instructions.go rename to pkg/github/toolset_instructions.go index 3a5fb54bb..bf2388a3d 100644 --- a/pkg/github/instructions.go +++ b/pkg/github/toolset_instructions.go @@ -1,75 +1,41 @@ package github -import ( - "os" - "slices" - "strings" -) - -// GenerateInstructions creates server instructions based on enabled toolsets -func GenerateInstructions(enabledToolsets []string) string { - // For testing - add a flag to disable instructions - if os.Getenv("DISABLE_INSTRUCTIONS") == "true" { - return "" // Baseline mode - } - - var instructions []string +import "github.com/github/github-mcp-server/pkg/inventory" - // Core instruction - always included if context toolset enabled - if slices.Contains(enabledToolsets, "context") { - instructions = append(instructions, "Always call 'get_me' first to understand current user permissions and context.") - } - - // Individual toolset instructions - for _, toolset := range enabledToolsets { - if inst := getToolsetInstructions(toolset, enabledToolsets); inst != "" { - instructions = append(instructions, inst) - } - } +// Toolset instruction functions - these generate context-aware instructions for each toolset. +// They are called during inventory build to generate server instructions. - // Base instruction with context management - baseInstruction := `The GitHub MCP Server provides tools to interact with GitHub platform. - -Tool selection guidance: - 1. Use 'list_*' tools for broad, simple retrieval and pagination of all items of a type (e.g., all issues, all PRs, all branches) with basic filtering. - 2. Use 'search_*' tools for targeted queries with specific criteria, keywords, or complex filters (e.g., issues with certain text, PRs by author, code containing functions). - -Context management: - 1. Use pagination whenever possible with batches of 5-10 items. - 2. Use minimal_output parameter set to true if the full information is not needed to accomplish a task. - -Tool usage guidance: - 1. For 'search_*' tools: Use separate 'sort' and 'order' parameters if available for sorting results - do not include 'sort:' syntax in query strings. Query strings should contain only search criteria (e.g., 'org:google language:python'), not sorting instructions.` +func generateContextToolsetInstructions(_ *inventory.Inventory) string { + return "Always call 'get_me' first to understand current user permissions and context." +} - allInstructions := []string{baseInstruction} - allInstructions = append(allInstructions, instructions...) +func generateIssuesToolsetInstructions(_ *inventory.Inventory) string { + return `## Issues - return strings.Join(allInstructions, " ") +Check 'list_issue_types' first for organizations to use proper issue types. Use 'search_issues' before creating new issues to avoid duplicates. Always set 'state_reason' when closing issues.` } -// getToolsetInstructions returns specific instructions for individual toolsets -func getToolsetInstructions(toolset string, enabledToolsets []string) string { - switch toolset { - case "pull_requests": - pullRequestInstructions := `## Pull Requests +func generatePullRequestsToolsetInstructions(inv *inventory.Inventory) string { + instructions := `## Pull Requests PR review workflow: Always use 'pull_request_review_write' with method 'create' to create a pending review, then 'add_comment_to_pending_review' to add comments, and finally 'pull_request_review_write' with method 'submit_pending' to submit the review for complex reviews with line-specific comments.` - if slices.Contains(enabledToolsets, "repos") { - pullRequestInstructions += ` + + if inv.HasToolset("repos") { + instructions += ` Before creating a pull request, search for pull request templates in the repository. Template files are called pull_request_template.md or they're located in '.github/PULL_REQUEST_TEMPLATE' directory. Use the template content to structure the PR description and then call create_pull_request tool.` - } - return pullRequestInstructions - case "issues": - return `## Issues + } + return instructions +} + +func generateDiscussionsToolsetInstructions(_ *inventory.Inventory) string { + return `## Discussions -Check 'list_issue_types' first for organizations to use proper issue types. Use 'search_issues' before creating new issues to avoid duplicates. Always set 'state_reason' when closing issues.` - case "discussions": - return `## Discussions - Use 'list_discussion_categories' to understand available categories before creating discussions. Filter by category for better organization.` - case "projects": - return `## Projects +} + +func generateProjectsToolsetInstructions(_ *inventory.Inventory) string { + return `## Projects Workflow: 1) list_project_fields (get field IDs), 2) list_project_items (with pagination), 3) optional updates. @@ -137,7 +103,4 @@ Common Qualifier Glossary (items): Never: - Infer field IDs; fetch via list_project_fields. - Drop 'fields' param on subsequent pages if field values are needed.` - default: - return "" - } } diff --git a/pkg/github/transport.go b/pkg/github/transport.go new file mode 100644 index 000000000..0a4372b23 --- /dev/null +++ b/pkg/github/transport.go @@ -0,0 +1,47 @@ +package github + +import ( + "net/http" + "strings" +) + +// GraphQLFeaturesTransport is an http.RoundTripper that adds GraphQL-Features +// header to requests based on context values. This is required for using +// non-GA GraphQL API features like the agent assignment API. +// +// This transport is used internally by the MCP server and is also exported +// for library consumers who need to build their own HTTP clients with +// GraphQL feature flag support. +// +// Usage: +// +// httpClient := &http.Client{ +// Transport: &github.GraphQLFeaturesTransport{ +// Transport: http.DefaultTransport, +// }, +// } +// gqlClient := githubv4.NewClient(httpClient) +// +// Then use withGraphQLFeatures(ctx, "feature_name") when calling GraphQL operations. +type GraphQLFeaturesTransport struct { + // Transport is the underlying HTTP transport. If nil, http.DefaultTransport is used. + Transport http.RoundTripper +} + +// RoundTrip implements http.RoundTripper. +func (t *GraphQLFeaturesTransport) RoundTrip(req *http.Request) (*http.Response, error) { + transport := t.Transport + if transport == nil { + transport = http.DefaultTransport + } + + // Clone the request to avoid mutating the original + req = req.Clone(req.Context()) + + // Check for GraphQL-Features in context and add header if present + if features := GetGraphQLFeatures(req.Context()); len(features) > 0 { + req.Header.Set("GraphQL-Features", strings.Join(features, ", ")) + } + + return transport.RoundTrip(req) +} diff --git a/pkg/github/transport_test.go b/pkg/github/transport_test.go new file mode 100644 index 000000000..c98108255 --- /dev/null +++ b/pkg/github/transport_test.go @@ -0,0 +1,151 @@ +package github + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGraphQLFeaturesTransport(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + features []string + expectedHeader string + hasHeader bool + }{ + { + name: "no features in context", + features: nil, + expectedHeader: "", + hasHeader: false, + }, + { + name: "single feature in context", + features: []string{"issues_copilot_assignment_api_support"}, + expectedHeader: "issues_copilot_assignment_api_support", + hasHeader: true, + }, + { + name: "multiple features in context", + features: []string{"feature1", "feature2", "feature3"}, + expectedHeader: "feature1, feature2, feature3", + hasHeader: true, + }, + { + name: "empty features slice", + features: []string{}, + expectedHeader: "", + hasHeader: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var capturedHeader string + var headerExists bool + + // Create a test server that captures the request header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeader = r.Header.Get("GraphQL-Features") + headerExists = r.Header.Get("GraphQL-Features") != "" + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Create the transport + transport := &GraphQLFeaturesTransport{ + Transport: http.DefaultTransport, + } + + // Create a request + ctx := context.Background() + if tc.features != nil { + ctx = withGraphQLFeatures(ctx, tc.features...) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil) + require.NoError(t, err) + + // Execute the request + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Verify the header + assert.Equal(t, tc.hasHeader, headerExists) + if tc.hasHeader { + assert.Equal(t, tc.expectedHeader, capturedHeader) + } + }) + } +} + +func TestGraphQLFeaturesTransport_NilTransport(t *testing.T) { + t.Parallel() + + var capturedHeader string + + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeader = r.Header.Get("GraphQL-Features") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Create the transport with nil Transport (should use DefaultTransport) + transport := &GraphQLFeaturesTransport{ + Transport: nil, + } + + // Create a request with features + ctx := withGraphQLFeatures(context.Background(), "test_feature") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil) + require.NoError(t, err) + + // Execute the request + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Verify the header was added + assert.Equal(t, "test_feature", capturedHeader) +} + +func TestGraphQLFeaturesTransport_DoesNotMutateOriginalRequest(t *testing.T) { + t.Parallel() + + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Create the transport + transport := &GraphQLFeaturesTransport{ + Transport: http.DefaultTransport, + } + + // Create a request with features + ctx := withGraphQLFeatures(context.Background(), "test_feature") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil) + require.NoError(t, err) + + // Store the original header value + originalHeader := req.Header.Get("GraphQL-Features") + + // Execute the request + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Verify the original request was not mutated + assert.Equal(t, originalHeader, req.Header.Get("GraphQL-Features")) +} diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index a0ed2baee..ff2d06d5d 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -2,6 +2,7 @@ package inventory import ( "context" + "fmt" "sort" "strings" ) @@ -33,12 +34,13 @@ type Builder struct { deprecatedAliases map[string]string // Configuration options (processed at Build time) - readOnly bool - toolsetIDs []string // raw input, processed at Build() - toolsetIDsIsNil bool // tracks if nil was passed (nil = defaults) - additionalTools []string // raw input, processed at Build() - featureChecker FeatureFlagChecker - filters []ToolFilter // filters to apply to all tools + readOnly bool + toolsetIDs []string // raw input, processed at Build() + toolsetIDsIsNil bool // tracks if nil was passed (nil = defaults) + additionalTools []string // raw input, processed at Build() + featureChecker FeatureFlagChecker + filters []ToolFilter // filters to apply to all tools + generateInstructions bool } // NewBuilder creates a new Builder. @@ -83,6 +85,11 @@ func (b *Builder) WithReadOnly(readOnly bool) *Builder { return b } +func (b *Builder) WithServerInstructions() *Builder { + b.generateInstructions = true + return b +} + // WithToolsets specifies which toolsets should be enabled. // Special keywords: // - "all": enables all toolsets @@ -101,6 +108,7 @@ func (b *Builder) WithToolsets(toolsetIDs []string) *Builder { // WithTools specifies additional tools that bypass toolset filtering. // These tools are additive - they will be included even if their toolset is not enabled. // Read-only filtering still applies to these tools. +// Input is cleaned (trimmed, deduplicated) during Build(). // Deprecated tool aliases are automatically resolved to their canonical names during Build(). // Returns self for chaining. func (b *Builder) WithTools(toolNames []string) *Builder { @@ -127,11 +135,33 @@ func (b *Builder) WithFilter(filter ToolFilter) *Builder { return b } +// cleanTools trims whitespace and removes duplicates from tool names. +// Empty strings after trimming are excluded. +func cleanTools(tools []string) []string { + seen := make(map[string]bool) + var cleaned []string + for _, name := range tools { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + continue + } + if !seen[trimmed] { + seen[trimmed] = true + cleaned = append(cleaned, trimmed) + } + } + return cleaned +} + // Build creates the final Inventory with all configuration applied. // This processes toolset filtering, tool name resolution, and sets up // the inventory for use. The returned Inventory is ready for use with // AvailableTools(), RegisterAll(), etc. -func (b *Builder) Build() *Inventory { +// +// Build returns an error if any tools specified via WithTools() are not recognized +// (i.e., they don't exist in the tool set and are not deprecated aliases). +// This ensures invalid tool configurations fail fast at build time. +func (b *Builder) Build() (*Inventory, error) { r := &Inventory{ tools: b.tools, resourceTemplates: b.resourceTemplates, @@ -145,20 +175,44 @@ func (b *Builder) Build() *Inventory { // Process toolsets and pre-compute metadata in a single pass r.enabledToolsets, r.unrecognizedToolsets, r.toolsetIDs, r.toolsetIDSet, r.defaultToolsetIDs, r.toolsetDescriptions = b.processToolsets() - // Process additional tools (resolve aliases) + // Build set of valid tool names for validation + validToolNames := make(map[string]bool, len(b.tools)) + for i := range b.tools { + validToolNames[b.tools[i].Tool.Name] = true + } + + // Process additional tools (clean, resolve aliases, and track unrecognized) if len(b.additionalTools) > 0 { - r.additionalTools = make(map[string]bool, len(b.additionalTools)) - for _, name := range b.additionalTools { - // Resolve deprecated aliases to canonical names + cleanedTools := cleanTools(b.additionalTools) + + r.additionalTools = make(map[string]bool, len(cleanedTools)) + var unrecognizedTools []string + for _, name := range cleanedTools { + // Always include the original name - this handles the case where + // the tool exists but is controlled by a feature flag that's OFF. + r.additionalTools[name] = true + // Also include the canonical name if this is a deprecated alias. + // This handles the case where the feature flag is ON and only + // the new consolidated tool is available. if canonical, isAlias := b.deprecatedAliases[name]; isAlias { r.additionalTools[canonical] = true - } else { - r.additionalTools[name] = true + } else if !validToolNames[name] { + // Not a valid tool and not a deprecated alias - track as unrecognized + unrecognizedTools = append(unrecognizedTools, name) } } + + // Error out if there are unrecognized tools + if len(unrecognizedTools) > 0 { + return nil, fmt.Errorf("unrecognized tools: %s", strings.Join(unrecognizedTools, ", ")) + } + } + + if b.generateInstructions { + r.instructions = generateInstructions(r) } - return r + return r, nil } // processToolsets processes the toolsetIDs configuration and returns: diff --git a/pkg/inventory/filters.go b/pkg/inventory/filters.go index 991001a64..533bba552 100644 --- a/pkg/inventory/filters.go +++ b/pkg/inventory/filters.go @@ -178,33 +178,29 @@ func (r *Inventory) AvailablePrompts(ctx context.Context) []ServerPrompt { // filterToolsByName returns tools matching the given name, checking deprecated aliases. // Uses linear scan - optimized for single-lookup per-request scenarios (ForMCPRequest). +// Returns ALL tools matching the name to support feature-flagged tool variants +// (e.g., GetJobLogs and ActionsGetJobLogs both use name "get_job_logs" but are +// controlled by different feature flags). func (r *Inventory) filterToolsByName(name string) []ServerTool { - // First check for exact match + var result []ServerTool + // Check for exact matches - multiple tools may share the same name with different feature flags for i := range r.tools { if r.tools[i].Tool.Name == name { - return []ServerTool{r.tools[i]} + result = append(result, r.tools[i]) } } + if len(result) > 0 { + return result + } // Check if name is a deprecated alias if canonical, isAlias := r.deprecatedAliases[name]; isAlias { for i := range r.tools { if r.tools[i].Tool.Name == canonical { - return []ServerTool{r.tools[i]} + result = append(result, r.tools[i]) } } } - return []ServerTool{} -} - -// filterResourcesByURI returns resource templates matching the given URI pattern. -// Uses linear scan - optimized for single-lookup per-request scenarios (ForMCPRequest). -func (r *Inventory) filterResourcesByURI(uri string) []ServerResourceTemplate { - for i := range r.resourceTemplates { - if r.resourceTemplates[i].Template.URITemplate == uri { - return []ServerResourceTemplate{r.resourceTemplates[i]} - } - } - return []ServerResourceTemplate{} + return result } // filterPromptsByName returns prompts matching the given name. diff --git a/pkg/inventory/instructions.go b/pkg/inventory/instructions.go new file mode 100644 index 000000000..02e90cd20 --- /dev/null +++ b/pkg/inventory/instructions.go @@ -0,0 +1,43 @@ +package inventory + +import ( + "os" + "strings" +) + +// generateInstructions creates server instructions based on enabled toolsets +func generateInstructions(inv *Inventory) string { + // For testing - add a flag to disable instructions + if os.Getenv("DISABLE_INSTRUCTIONS") == "true" { + return "" // Baseline mode + } + + var instructions []string + + // Base instruction with context management + baseInstruction := `The GitHub MCP Server provides tools to interact with GitHub platform. + +Tool selection guidance: + 1. Use 'list_*' tools for broad, simple retrieval and pagination of all items of a type (e.g., all issues, all PRs, all branches) with basic filtering. + 2. Use 'search_*' tools for targeted queries with specific criteria, keywords, or complex filters (e.g., issues with certain text, PRs by author, code containing functions). + +Context management: + 1. Use pagination whenever possible with batches of 5-10 items. + 2. Use minimal_output parameter set to true if the full information is not needed to accomplish a task. + +Tool usage guidance: + 1. For 'search_*' tools: Use separate 'sort' and 'order' parameters if available for sorting results - do not include 'sort:' syntax in query strings. Query strings should contain only search criteria (e.g., 'org:google language:python'), not sorting instructions.` + + instructions = append(instructions, baseInstruction) + + // Collect instructions from each enabled toolset + for _, toolset := range inv.EnabledToolsets() { + if toolset.InstructionsFunc != nil { + if toolsetInstructions := toolset.InstructionsFunc(inv); toolsetInstructions != "" { + instructions = append(instructions, toolsetInstructions) + } + } + } + + return strings.Join(instructions, " ") +} diff --git a/pkg/inventory/instructions_test.go b/pkg/inventory/instructions_test.go new file mode 100644 index 000000000..e8e369b3d --- /dev/null +++ b/pkg/inventory/instructions_test.go @@ -0,0 +1,265 @@ +package inventory + +import ( + "os" + "strings" + "testing" +) + +// createTestInventory creates an inventory with the specified toolsets for testing. +// All toolsets are enabled by default using WithToolsets([]string{"all"}). +func createTestInventory(toolsets []ToolsetMetadata) *Inventory { + // Create tools for each toolset so they show up in AvailableToolsets() + var tools []ServerTool + for _, ts := range toolsets { + tools = append(tools, ServerTool{ + Toolset: ts, + }) + } + + inv, _ := NewBuilder(). + SetTools(tools). + WithToolsets([]string{"all"}). + Build() + + return inv +} + +func TestGenerateInstructions(t *testing.T) { + tests := []struct { + name string + toolsets []ToolsetMetadata + expectedEmpty bool + }{ + { + name: "empty toolsets", + toolsets: []ToolsetMetadata{}, + expectedEmpty: false, // base instructions are always included + }, + { + name: "toolset with instructions", + toolsets: []ToolsetMetadata{ + { + ID: "test", + Description: "Test toolset", + InstructionsFunc: func(_ *Inventory) string { + return "Test instructions" + }, + }, + }, + expectedEmpty: false, + }, + { + name: "toolset without instructions", + toolsets: []ToolsetMetadata{ + { + ID: "test", + Description: "Test toolset", + }, + }, + expectedEmpty: false, // base instructions still included + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inv := createTestInventory(tt.toolsets) + result := generateInstructions(inv) + + if tt.expectedEmpty { + if result != "" { + t.Errorf("Expected empty instructions but got: %s", result) + } + } else { + if result == "" { + t.Errorf("Expected non-empty instructions but got empty result") + } + } + }) + } +} + +func TestGenerateInstructionsWithDisableFlag(t *testing.T) { + tests := []struct { + name string + disableEnvValue string + expectedEmpty bool + }{ + { + name: "DISABLE_INSTRUCTIONS=true returns empty", + disableEnvValue: "true", + expectedEmpty: true, + }, + { + name: "DISABLE_INSTRUCTIONS=false returns normal instructions", + disableEnvValue: "false", + expectedEmpty: false, + }, + { + name: "DISABLE_INSTRUCTIONS unset returns normal instructions", + disableEnvValue: "", + expectedEmpty: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save original env value + originalValue := os.Getenv("DISABLE_INSTRUCTIONS") + defer func() { + if originalValue == "" { + os.Unsetenv("DISABLE_INSTRUCTIONS") + } else { + os.Setenv("DISABLE_INSTRUCTIONS", originalValue) + } + }() + + // Set test env value + if tt.disableEnvValue == "" { + os.Unsetenv("DISABLE_INSTRUCTIONS") + } else { + os.Setenv("DISABLE_INSTRUCTIONS", tt.disableEnvValue) + } + + inv := createTestInventory([]ToolsetMetadata{ + {ID: "test", Description: "Test"}, + }) + result := generateInstructions(inv) + + if tt.expectedEmpty { + if result != "" { + t.Errorf("Expected empty instructions but got: %s", result) + } + } else { + if result == "" { + t.Errorf("Expected non-empty instructions but got empty result") + } + } + }) + } +} + +func TestToolsetInstructionsFunc(t *testing.T) { + tests := []struct { + name string + toolsets []ToolsetMetadata + expectedToContain string + notExpectedToContain string + }{ + { + name: "toolset with context-aware instructions includes extra text when dependency present", + toolsets: []ToolsetMetadata{ + {ID: "repos", Description: "Repos"}, + { + ID: "pull_requests", + Description: "PRs", + InstructionsFunc: func(inv *Inventory) string { + instructions := "PR base instructions" + if inv.HasToolset("repos") { + instructions += " PR template instructions" + } + return instructions + }, + }, + }, + expectedToContain: "PR template instructions", + }, + { + name: "toolset with context-aware instructions excludes extra text when dependency missing", + toolsets: []ToolsetMetadata{ + { + ID: "pull_requests", + Description: "PRs", + InstructionsFunc: func(inv *Inventory) string { + instructions := "PR base instructions" + if inv.HasToolset("repos") { + instructions += " PR template instructions" + } + return instructions + }, + }, + }, + notExpectedToContain: "PR template instructions", + }, + { + name: "toolset without InstructionsFunc returns no toolset-specific instructions", + toolsets: []ToolsetMetadata{ + {ID: "test", Description: "Test without instructions"}, + }, + notExpectedToContain: "## Test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inv := createTestInventory(tt.toolsets) + result := generateInstructions(inv) + + if tt.expectedToContain != "" && !strings.Contains(result, tt.expectedToContain) { + t.Errorf("Expected result to contain '%s', but it did not. Result: %s", tt.expectedToContain, result) + } + + if tt.notExpectedToContain != "" && strings.Contains(result, tt.notExpectedToContain) { + t.Errorf("Did not expect result to contain '%s', but it did. Result: %s", tt.notExpectedToContain, result) + } + }) + } +} + +// TestGenerateInstructionsOnlyEnabledToolsets verifies that generateInstructions +// only includes instructions from enabled toolsets, not all available toolsets. +// This is a regression test for https://github.com/github/github-mcp-server/issues/1897 +func TestGenerateInstructionsOnlyEnabledToolsets(t *testing.T) { + // Create tools for multiple toolsets + reposToolset := ToolsetMetadata{ + ID: "repos", + Description: "Repository tools", + InstructionsFunc: func(_ *Inventory) string { + return "REPOS_INSTRUCTIONS" + }, + } + issuesToolset := ToolsetMetadata{ + ID: "issues", + Description: "Issue tools", + InstructionsFunc: func(_ *Inventory) string { + return "ISSUES_INSTRUCTIONS" + }, + } + prsToolset := ToolsetMetadata{ + ID: "pull_requests", + Description: "PR tools", + InstructionsFunc: func(_ *Inventory) string { + return "PRS_INSTRUCTIONS" + }, + } + + tools := []ServerTool{ + {Toolset: reposToolset}, + {Toolset: issuesToolset}, + {Toolset: prsToolset}, + } + + // Build inventory with only "repos" toolset enabled + inv, err := NewBuilder(). + SetTools(tools). + WithToolsets([]string{"repos"}). + Build() + if err != nil { + t.Fatalf("Failed to build inventory: %v", err) + } + + result := generateInstructions(inv) + + // Should contain instructions from enabled toolset + if !strings.Contains(result, "REPOS_INSTRUCTIONS") { + t.Errorf("Expected instructions to contain 'REPOS_INSTRUCTIONS' for enabled toolset, but it did not. Result: %s", result) + } + + // Should NOT contain instructions from non-enabled toolsets + if strings.Contains(result, "ISSUES_INSTRUCTIONS") { + t.Errorf("Did not expect instructions to contain 'ISSUES_INSTRUCTIONS' for disabled toolset, but it did. Result: %s", result) + } + if strings.Contains(result, "PRS_INSTRUCTIONS") { + t.Errorf("Did not expect instructions to contain 'PRS_INSTRUCTIONS' for disabled toolset, but it did. Result: %s", result) + } +} diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index f3691e38a..e2cd3a9e6 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -58,6 +58,8 @@ type Inventory struct { filters []ToolFilter // unrecognizedToolsets holds toolset IDs that were requested but don't match any registered toolsets unrecognizedToolsets []string + // server instructions hold high-level instructions for agents to use the server effectively + instructions string } // UnrecognizedToolsets returns toolset IDs that were passed to WithToolsets but don't @@ -91,7 +93,7 @@ const ( // - MCPMethodToolsList: All available tools (no resources/prompts) // - MCPMethodToolsCall: Only the named tool // - MCPMethodResourcesList, MCPMethodResourcesTemplatesList: All available resources (no tools/prompts) -// - MCPMethodResourcesRead: Only the named resource template +// - MCPMethodResourcesRead: All resources (SDK handles URI template matching) // - MCPMethodPromptsList: All available prompts (no tools/resources) // - MCPMethodPromptsGet: Only the named prompt // - Unknown methods: Empty (no items registered) @@ -134,10 +136,8 @@ func (r *Inventory) ForMCPRequest(method string, itemName string) *Inventory { case MCPMethodResourcesList, MCPMethodResourcesTemplatesList: result.tools, result.prompts = nil, nil case MCPMethodResourcesRead: + // Keep all resources registered - SDK handles URI template matching internally result.tools, result.prompts = nil, nil - if itemName != "" { - result.resourceTemplates = r.filterResourcesByURI(itemName) - } case MCPMethodPromptsList: result.tools, result.resourceTemplates = nil, nil case MCPMethodPromptsGet: @@ -294,3 +294,29 @@ func (r *Inventory) AvailableToolsets(exclude ...ToolsetID) []ToolsetMetadata { } return result } + +// EnabledToolsets returns the unique toolsets that are enabled based on current filters. +// This is similar to AvailableToolsets but respects the enabledToolsets filter. +// Returns toolsets in sorted order by toolset ID. +func (r *Inventory) EnabledToolsets() []ToolsetMetadata { + // Get all available toolsets first (already sorted by ID) + allToolsets := r.AvailableToolsets() + + // If no filter is set, all toolsets are enabled + if r.enabledToolsets == nil { + return allToolsets + } + + // Filter to only enabled toolsets + var result []ToolsetMetadata + for _, ts := range allToolsets { + if r.enabledToolsets[ts.ID] { + result = append(result, ts) + } + } + return result +} + +func (r *Inventory) Instructions() string { + return r.instructions +} diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 41e94b8d9..bb3337af0 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -7,8 +7,18 @@ import ( "testing" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" ) +// mustBuild is a test helper that calls Build() and fails the test if an error occurs. +// Use this for tests where Build() is not expected to fail. +func mustBuild(t *testing.T, b *Builder) *Inventory { + t.Helper() + inv, err := b.Build() + require.NoError(t, err) + return inv +} + // testToolsetMetadata returns a ToolsetMetadata for testing func testToolsetMetadata(id string) ToolsetMetadata { return ToolsetMetadata{ @@ -65,7 +75,7 @@ func mockTool(name string, toolsetID string, readOnly bool) ServerTool { } func TestNewRegistryEmpty(t *testing.T) { - reg := NewBuilder().Build() + reg := mustBuild(t, NewBuilder()) if len(reg.AvailableTools(context.Background())) != 0 { t.Fatalf("Expected tools to be empty") } @@ -84,7 +94,7 @@ func TestNewRegistryWithTools(t *testing.T) { mockTool("tool3", "toolset2", true), } - reg := NewBuilder().SetTools(tools).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools)) if len(reg.AllTools()) != 3 { t.Errorf("Expected 3 tools, got %d", len(reg.AllTools())) @@ -98,7 +108,7 @@ func TestAvailableTools_NoFilters(t *testing.T) { mockTool("tool_c", "toolset2", true), } - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) available := reg.AvailableTools(context.Background()) if len(available) != 3 { @@ -121,14 +131,14 @@ func TestWithReadOnly(t *testing.T) { } // Build without read-only - should have both tools - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) allTools := reg.AvailableTools(context.Background()) if len(allTools) != 2 { t.Fatalf("Expected 2 tools without read-only, got %d", len(allTools)) } // Build with read-only - should filter out write tools - readOnlyReg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true).Build() + readOnlyReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true)) readOnlyTools := readOnlyReg.AvailableTools(context.Background()) if len(readOnlyTools) != 1 { t.Fatalf("Expected 1 tool in read-only, got %d", len(readOnlyTools)) @@ -146,14 +156,14 @@ func TestWithToolsets(t *testing.T) { } // Build with all toolsets - allReg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + allReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) allTools := allReg.AvailableTools(context.Background()) if len(allTools) != 3 { t.Fatalf("Expected 3 tools without filter, got %d", len(allTools)) } // Build with specific toolsets - filteredReg := NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1", "toolset3"}).Build() + filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1", "toolset3"})) filteredTools := filteredReg.AvailableTools(context.Background()) if len(filteredTools) != 2 { @@ -177,7 +187,7 @@ func TestWithToolsetsTrimsWhitespace(t *testing.T) { } // Whitespace should be trimmed - filteredReg := NewBuilder().SetTools(tools).WithToolsets([]string{" toolset1 ", " toolset2 "}).Build() + filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{" toolset1 ", " toolset2 "})) filteredTools := filteredReg.AvailableTools(context.Background()) if len(filteredTools) != 2 { @@ -191,7 +201,7 @@ func TestWithToolsetsDeduplicates(t *testing.T) { } // Duplicates should be removed - filteredReg := NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1", "toolset1", " toolset1 "}).Build() + filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1", "toolset1", " toolset1 "})) filteredTools := filteredReg.AvailableTools(context.Background()) if len(filteredTools) != 1 { @@ -205,7 +215,7 @@ func TestWithToolsetsIgnoresEmptyStrings(t *testing.T) { } // Empty strings should be ignored - filteredReg := NewBuilder().SetTools(tools).WithToolsets([]string{"", "toolset1", " ", ""}).Build() + filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"", "toolset1", " ", ""})) filteredTools := filteredReg.AvailableTools(context.Background()) if len(filteredTools) != 1 { @@ -253,7 +263,7 @@ func TestUnrecognizedToolsets(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - filtered := NewBuilder().SetTools(tools).WithToolsets(tt.input).Build() + filtered := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets(tt.input)) unrecognized := filtered.UnrecognizedToolsets() if len(unrecognized) != len(tt.expectedUnrecognized) { @@ -270,6 +280,109 @@ func TestUnrecognizedToolsets(t *testing.T) { } } +func TestBuildErrorsOnUnrecognizedTools(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), + mockTool("tool2", "toolset2", true), + } + + deprecatedAliases := map[string]string{ + "old_tool": "tool1", + } + + tests := []struct { + name string + withTools []string + expectError bool + errorContains string + }{ + { + name: "all valid", + withTools: []string{"tool1", "tool2"}, + expectError: false, + }, + { + name: "one invalid", + withTools: []string{"tool1", "blabla"}, + expectError: true, + errorContains: "blabla", + }, + { + name: "multiple invalid", + withTools: []string{"invalid1", "tool1", "invalid2"}, + expectError: true, + errorContains: "invalid1", + }, + { + name: "deprecated alias is valid", + withTools: []string{"old_tool"}, + expectError: false, + }, + { + name: "mixed valid and deprecated alias", + withTools: []string{"old_tool", "tool2"}, + expectError: false, + }, + { + name: "empty input", + withTools: []string{}, + expectError: false, + }, + { + name: "whitespace trimmed from valid tool", + withTools: []string{" tool1 ", " tool2 "}, + expectError: false, + }, + { + name: "whitespace trimmed from invalid tool", + withTools: []string{" invalid_tool "}, + expectError: true, + errorContains: "invalid_tool", + }, + { + name: "duplicate tools deduplicated", + withTools: []string{"tool1", "tool1"}, + expectError: false, + }, + { + name: "duplicate invalid tools deduplicated", + withTools: []string{"blabla", "blabla"}, + expectError: true, + errorContains: "blabla", + }, + { + name: "mixed whitespace and duplicates", + withTools: []string{" tool1 ", "tool1", " tool1 "}, + expectError: false, + }, + { + name: "empty strings ignored", + withTools: []string{"", "tool1", " ", ""}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inv, err := NewBuilder(). + SetTools(tools). + WithDeprecatedAliases(deprecatedAliases). + WithToolsets([]string{"all"}). + WithTools(tt.withTools). + Build() + + if tt.expectError { + require.Error(t, err, "Expected error for unrecognized tools") + require.Contains(t, err.Error(), tt.errorContains) + require.Nil(t, inv) + } else { + require.NoError(t, err) + require.NotNil(t, inv) + } + }) + } +} + func TestWithTools(t *testing.T) { tools := []ServerTool{ mockTool("tool1", "toolset1", true), @@ -279,7 +392,7 @@ func TestWithTools(t *testing.T) { // WithTools adds additional tools that bypass toolset filtering // When combined with WithToolsets([]), only the additional tools should be available - filteredReg := NewBuilder().SetTools(tools).WithToolsets([]string{}).WithTools([]string{"tool1", "tool3"}).Build() + filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{}).WithTools([]string{"tool1", "tool3"})) filteredTools := filteredReg.AvailableTools(context.Background()) if len(filteredTools) != 2 { @@ -304,7 +417,7 @@ func TestChainedFilters(t *testing.T) { } // Chain read-only and toolset filter - filtered := NewBuilder().SetTools(tools).WithReadOnly(true).WithToolsets([]string{"toolset1"}).Build() + filtered := mustBuild(t, NewBuilder().SetTools(tools).WithReadOnly(true).WithToolsets([]string{"toolset1"})) result := filtered.AvailableTools(context.Background()) if len(result) != 1 { @@ -322,7 +435,7 @@ func TestToolsetIDs(t *testing.T) { mockTool("tool3", "toolset_b", true), // duplicate toolset } - reg := NewBuilder().SetTools(tools).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools)) ids := reg.ToolsetIDs() if len(ids) != 2 { @@ -341,7 +454,7 @@ func TestToolsetDescriptions(t *testing.T) { mockTool("tool2", "toolset2", true), } - reg := NewBuilder().SetTools(tools).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools)) descriptions := reg.ToolsetDescriptions() if len(descriptions) != 2 { @@ -360,7 +473,7 @@ func TestToolsForToolset(t *testing.T) { mockTool("tool3", "toolset2", true), } - reg := NewBuilder().SetTools(tools).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools)) toolset1Tools := reg.ToolsForToolset("toolset1") if len(toolset1Tools) != 2 { @@ -373,10 +486,10 @@ func TestWithDeprecatedAliases(t *testing.T) { mockTool("new_name", "toolset1", true), } - reg := NewBuilder().SetTools(tools).WithDeprecatedAliases(map[string]string{ + reg := mustBuild(t, NewBuilder().SetTools(tools).WithDeprecatedAliases(map[string]string{ "old_name": "new_name", "get_issue": "issue_read", - }).Build() + })) // Test resolving aliases resolved, aliasesUsed := reg.ResolveToolAliases([]string{"old_name"}) @@ -394,10 +507,10 @@ func TestResolveToolAliases(t *testing.T) { mockTool("some_tool", "toolset1", true), } - reg := NewBuilder().SetTools(tools). + reg := mustBuild(t, NewBuilder().SetTools(tools). WithDeprecatedAliases(map[string]string{ "get_issue": "issue_read", - }).Build() + })) // Test resolving a mix of aliases and canonical names input := []string{"get_issue", "some_tool"} @@ -426,7 +539,7 @@ func TestFindToolByName(t *testing.T) { mockTool("issue_read", "toolset1", true), } - reg := NewBuilder().SetTools(tools).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools)) // Find by name tool, toolsetID, err := reg.FindToolByName("issue_read") @@ -456,7 +569,7 @@ func TestWithToolsAdditive(t *testing.T) { // Test WithTools bypasses toolset filtering // Enable only toolset2, but add issue_read as additional tool - filtered := NewBuilder().SetTools(tools).WithToolsets([]string{"toolset2"}).WithTools([]string{"issue_read"}).Build() + filtered := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"toolset2"}).WithTools([]string{"issue_read"})) available := filtered.AvailableTools(context.Background()) if len(available) != 2 { @@ -476,7 +589,7 @@ func TestWithToolsAdditive(t *testing.T) { } // Test WithTools respects read-only mode - readOnlyFiltered := NewBuilder().SetTools(tools).WithReadOnly(true).WithTools([]string{"issue_write"}).Build() + readOnlyFiltered := mustBuild(t, NewBuilder().SetTools(tools).WithReadOnly(true).WithTools([]string{"issue_write"})) available = readOnlyFiltered.AvailableTools(context.Background()) // issue_write should be excluded because read-only applies to additional tools too @@ -486,12 +599,10 @@ func TestWithToolsAdditive(t *testing.T) { } } - // Test WithTools with non-existent tool (should not error, just won't match anything) - nonexistent := NewBuilder().SetTools(tools).WithToolsets([]string{}).WithTools([]string{"nonexistent"}).Build() - available = nonexistent.AvailableTools(context.Background()) - if len(available) != 0 { - t.Errorf("expected 0 tools for non-existent additional tool, got %d", len(available)) - } + // Test WithTools with non-existent tool (should error during Build) + _, err := NewBuilder().SetTools(tools).WithToolsets([]string{}).WithTools([]string{"nonexistent"}).Build() + require.Error(t, err, "expected error for non-existent tool") + require.Contains(t, err.Error(), "nonexistent") } func TestWithToolsResolvesAliases(t *testing.T) { @@ -500,13 +611,12 @@ func TestWithToolsResolvesAliases(t *testing.T) { } // Using deprecated alias should resolve to canonical name - filtered := NewBuilder().SetTools(tools). + filtered := mustBuild(t, NewBuilder().SetTools(tools). WithDeprecatedAliases(map[string]string{ "get_issue": "issue_read", }). WithToolsets([]string{}). - WithTools([]string{"get_issue"}). - Build() + WithTools([]string{"get_issue"})) available := filtered.AvailableTools(context.Background()) if len(available) != 1 { @@ -522,7 +632,7 @@ func TestHasToolset(t *testing.T) { mockTool("tool1", "toolset1", true), } - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) if !reg.HasToolset("toolset1") { t.Error("expected HasToolset to return true for existing toolset") @@ -539,14 +649,14 @@ func TestEnabledToolsetIDs(t *testing.T) { } // Without filter, all toolsets are enabled - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) ids := reg.EnabledToolsetIDs() if len(ids) != 2 { t.Fatalf("Expected 2 enabled toolset IDs, got %d", len(ids)) } // With filter - filtered := NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1"}).Build() + filtered := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1"})) filteredIDs := filtered.EnabledToolsetIDs() if len(filteredIDs) != 1 { t.Fatalf("Expected 1 enabled toolset ID, got %d", len(filteredIDs)) @@ -563,7 +673,7 @@ func TestAllTools(t *testing.T) { } // Even with read-only filter, AllTools returns everything - readOnlyReg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true).Build() + readOnlyReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true)) allTools := readOnlyReg.AllTools() if len(allTools) != 2 { @@ -628,7 +738,7 @@ func TestForMCPRequest_Initialize(t *testing.T) { mockPrompt("prompt1", "repos"), } - reg := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest(MCPMethodInitialize, "") // Initialize should return empty - capabilities come from ServerOptions @@ -655,7 +765,7 @@ func TestForMCPRequest_ToolsList(t *testing.T) { mockPrompt("prompt1", "repos"), } - reg := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest(MCPMethodToolsList, "") // tools/list should return all tools, no resources or prompts @@ -677,7 +787,7 @@ func TestForMCPRequest_ToolsCall(t *testing.T) { mockTool("list_repos", "repos", true), } - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest(MCPMethodToolsCall, "get_me") available := filtered.AvailableTools(context.Background()) @@ -694,7 +804,7 @@ func TestForMCPRequest_ToolsCall_NotFound(t *testing.T) { mockTool("get_me", "context", true), } - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest(MCPMethodToolsCall, "nonexistent") if len(filtered.AvailableTools(context.Background())) != 0 { @@ -708,11 +818,11 @@ func TestForMCPRequest_ToolsCall_DeprecatedAlias(t *testing.T) { mockTool("list_commits", "repos", true), } - reg := NewBuilder().SetTools(tools). + reg := mustBuild(t, NewBuilder().SetTools(tools). WithToolsets([]string{"all"}). WithDeprecatedAliases(map[string]string{ "old_get_me": "get_me", - }).Build() + })) // Request using the deprecated alias filtered := reg.ForMCPRequest(MCPMethodToolsCall, "old_get_me") @@ -732,7 +842,7 @@ func TestForMCPRequest_ToolsCall_RespectsFilters(t *testing.T) { } // Apply read-only filter at build time, then ForMCPRequest - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true)) filtered := reg.ForMCPRequest(MCPMethodToolsCall, "create_issue") // The tool exists in the filtered group, but AvailableTools respects read-only @@ -754,7 +864,7 @@ func TestForMCPRequest_ResourcesList(t *testing.T) { mockPrompt("prompt1", "repos"), } - reg := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest(MCPMethodResourcesList, "") if len(filtered.AvailableTools(context.Background())) != 0 { @@ -774,18 +884,16 @@ func TestForMCPRequest_ResourcesRead(t *testing.T) { mockResource("res2", "repos", "branch://{owner}/{repo}/{branch}"), } - reg := NewBuilder().SetResources(resources).WithToolsets([]string{"all"}).Build() - filtered := reg.ForMCPRequest(MCPMethodResourcesRead, "repo://{owner}/{repo}") + reg := mustBuild(t, NewBuilder().SetResources(resources).WithToolsets([]string{"all"})) + // Pass a concrete URI - all resources remain registered, SDK handles matching + filtered := reg.ForMCPRequest(MCPMethodResourcesRead, "repo://owner/repo") + // All resources should be available - SDK handles URI template matching internally available := filtered.AvailableResourceTemplates(context.Background()) - if len(available) != 1 { - t.Fatalf("Expected 1 resource for resources/read, got %d", len(available)) - } - if available[0].Template.URITemplate != "repo://{owner}/{repo}" { - t.Errorf("Expected URI template 'repo://{owner}/{repo}', got %q", available[0].Template.URITemplate) + if len(available) != 2 { + t.Fatalf("Expected 2 resources for resources/read (SDK handles matching), got %d", len(available)) } } - func TestForMCPRequest_PromptsList(t *testing.T) { tools := []ServerTool{ mockTool("tool1", "repos", true), @@ -798,7 +906,7 @@ func TestForMCPRequest_PromptsList(t *testing.T) { mockPrompt("prompt2", "issues"), } - reg := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest(MCPMethodPromptsList, "") if len(filtered.AvailableTools(context.Background())) != 0 { @@ -818,7 +926,7 @@ func TestForMCPRequest_PromptsGet(t *testing.T) { mockPrompt("prompt2", "issues"), } - reg := NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest(MCPMethodPromptsGet, "prompt1") available := filtered.AvailablePrompts(context.Background()) @@ -841,7 +949,7 @@ func TestForMCPRequest_UnknownMethod(t *testing.T) { mockPrompt("prompt1", "repos"), } - reg := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest("unknown/method", "") // Unknown methods should return empty @@ -868,7 +976,7 @@ func TestForMCPRequest_DoesNotMutateOriginal(t *testing.T) { mockPrompt("prompt1", "repos"), } - original := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build() + original := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"})) filtered := original.ForMCPRequest(MCPMethodToolsCall, "tool1") // Original should be unchanged @@ -903,10 +1011,9 @@ func TestForMCPRequest_ChainedWithOtherFilters(t *testing.T) { } // Chain: default toolsets -> read-only -> specific method - reg := NewBuilder().SetTools(tools). + reg := mustBuild(t, NewBuilder().SetTools(tools). WithToolsets([]string{"default"}). - WithReadOnly(true). - Build() + WithReadOnly(true)) filtered := reg.ForMCPRequest(MCPMethodToolsList, "") available := filtered.AvailableTools(context.Background()) @@ -944,7 +1051,7 @@ func TestForMCPRequest_ResourcesTemplatesList(t *testing.T) { mockResource("res1", "repos", "repo://{owner}/{repo}"), } - reg := NewBuilder().SetTools(tools).SetResources(resources).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest(MCPMethodResourcesTemplatesList, "") // Same behavior as resources/list @@ -994,7 +1101,7 @@ func TestFeatureFlagEnable(t *testing.T) { } // Without feature checker, tool with FeatureFlagEnable should be excluded - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) available := reg.AvailableTools(context.Background()) if len(available) != 1 { t.Fatalf("Expected 1 tool without feature checker, got %d", len(available)) @@ -1005,7 +1112,7 @@ func TestFeatureFlagEnable(t *testing.T) { // With feature checker returning false, tool should still be excluded checkerFalse := func(_ context.Context, _ string) (bool, error) { return false, nil } - regFalse := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerFalse).Build() + regFalse := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerFalse)) availableFalse := regFalse.AvailableTools(context.Background()) if len(availableFalse) != 1 { t.Fatalf("Expected 1 tool with false checker, got %d", len(availableFalse)) @@ -1015,7 +1122,7 @@ func TestFeatureFlagEnable(t *testing.T) { checkerTrue := func(_ context.Context, flag string) (bool, error) { return flag == "my_feature", nil } - regTrue := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerTrue).Build() + regTrue := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerTrue)) availableTrue := regTrue.AvailableTools(context.Background()) if len(availableTrue) != 2 { t.Fatalf("Expected 2 tools with true checker, got %d", len(availableTrue)) @@ -1029,7 +1136,7 @@ func TestFeatureFlagDisable(t *testing.T) { } // Without feature checker, tool with FeatureFlagDisable should be included (flag is false) - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) available := reg.AvailableTools(context.Background()) if len(available) != 2 { t.Fatalf("Expected 2 tools without feature checker, got %d", len(available)) @@ -1039,7 +1146,7 @@ func TestFeatureFlagDisable(t *testing.T) { checkerTrue := func(_ context.Context, flag string) (bool, error) { return flag == "kill_switch", nil } - regFiltered := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerTrue).Build() + regFiltered := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerTrue)) availableFiltered := regFiltered.AvailableTools(context.Background()) if len(availableFiltered) != 1 { t.Fatalf("Expected 1 tool with kill_switch enabled, got %d", len(availableFiltered)) @@ -1057,21 +1164,21 @@ func TestFeatureFlagBoth(t *testing.T) { // Enable flag not set -> excluded checker1 := func(_ context.Context, _ string) (bool, error) { return false, nil } - reg1 := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker1).Build() + reg1 := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker1)) if len(reg1.AvailableTools(context.Background())) != 0 { t.Error("Tool should be excluded when enable flag is false") } // Enable flag set, disable flag not set -> included checker2 := func(_ context.Context, flag string) (bool, error) { return flag == "new_feature", nil } - reg2 := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker2).Build() + reg2 := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker2)) if len(reg2.AvailableTools(context.Background())) != 1 { t.Error("Tool should be included when enable flag is true and disable flag is false") } // Enable flag set, disable flag also set -> excluded (disable wins) checker3 := func(_ context.Context, _ string) (bool, error) { return true, nil } - reg3 := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker3).Build() + reg3 := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker3)) if len(reg3.AvailableTools(context.Background())) != 0 { t.Error("Tool should be excluded when both flags are true (disable wins)") } @@ -1086,7 +1193,7 @@ func TestFeatureFlagError(t *testing.T) { checkerError := func(_ context.Context, _ string) (bool, error) { return false, fmt.Errorf("simulated error") } - reg := NewBuilder().SetTools(tools).WithFeatureChecker(checkerError).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithFeatureChecker(checkerError)) available := reg.AvailableTools(context.Background()) if len(available) != 0 { t.Errorf("Expected 0 tools when checker errors, got %d", len(available)) @@ -1104,7 +1211,7 @@ func TestFeatureFlagResources(t *testing.T) { } // Without checker, resource with enable flag should be excluded - reg := NewBuilder().SetResources(resources).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetResources(resources).WithToolsets([]string{"all"})) available := reg.AvailableResourceTemplates(context.Background()) if len(available) != 1 { t.Fatalf("Expected 1 resource without checker, got %d", len(available)) @@ -1112,7 +1219,7 @@ func TestFeatureFlagResources(t *testing.T) { // With checker returning true, both should be included checker := func(_ context.Context, _ string) (bool, error) { return true, nil } - regWithChecker := NewBuilder().SetResources(resources).WithToolsets([]string{"all"}).WithFeatureChecker(checker).Build() + regWithChecker := mustBuild(t, NewBuilder().SetResources(resources).WithToolsets([]string{"all"}).WithFeatureChecker(checker)) if len(regWithChecker.AvailableResourceTemplates(context.Background())) != 2 { t.Errorf("Expected 2 resources with checker, got %d", len(regWithChecker.AvailableResourceTemplates(context.Background()))) } @@ -1129,7 +1236,7 @@ func TestFeatureFlagPrompts(t *testing.T) { } // Without checker, prompt with enable flag should be excluded - reg := NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"})) available := reg.AvailablePrompts(context.Background()) if len(available) != 1 { t.Fatalf("Expected 1 prompt without checker, got %d", len(available)) @@ -1137,7 +1244,7 @@ func TestFeatureFlagPrompts(t *testing.T) { // With checker returning true, both should be included checker := func(_ context.Context, _ string) (bool, error) { return true, nil } - regWithChecker := NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}).WithFeatureChecker(checker).Build() + regWithChecker := mustBuild(t, NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}).WithFeatureChecker(checker)) if len(regWithChecker.AvailablePrompts(context.Background())) != 2 { t.Errorf("Expected 2 prompts with checker, got %d", len(regWithChecker.AvailablePrompts(context.Background()))) } @@ -1220,7 +1327,7 @@ func TestServerToolEnabled(t *testing.T) { tool := mockTool("test_tool", "toolset1", true) tool.Enabled = tt.enabledFunc - reg := NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"all"})) available := reg.AvailableTools(context.Background()) if len(available) != tt.expectedCount { @@ -1252,7 +1359,7 @@ func TestServerToolEnabledWithContext(t *testing.T) { return user != nil && user.(string) == "authorized", nil } - reg := NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"all"})) // Without user in context - tool should be excluded available := reg.AvailableTools(context.Background()) @@ -1288,11 +1395,10 @@ func TestBuilderWithFilter(t *testing.T) { return tool.Tool.Name != "tool2", nil } - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets([]string{"all"}). - WithFilter(filter). - Build() + WithFilter(filter)) available := reg.AvailableTools(context.Background()) if len(available) != 2 { @@ -1324,12 +1430,11 @@ func TestBuilderWithMultipleFilters(t *testing.T) { return tool.Tool.Name != "tool3", nil } - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets([]string{"all"}). WithFilter(filter1). - WithFilter(filter2). - Build() + WithFilter(filter2)) available := reg.AvailableTools(context.Background()) if len(available) != 2 { @@ -1359,11 +1464,10 @@ func TestBuilderFilterError(t *testing.T) { return false, fmt.Errorf("filter error") } - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets([]string{"all"}). - WithFilter(filter). - Build() + WithFilter(filter)) available := reg.AvailableTools(context.Background()) if len(available) != 0 { @@ -1389,11 +1493,10 @@ func TestBuilderFilterWithContext(t *testing.T) { return true, nil } - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets([]string{"all"}). - WithFilter(filter). - Build() + WithFilter(filter)) // With public scope - private_tool should be excluded ctxPublic := context.WithValue(context.Background(), scopeKey, "public") @@ -1422,10 +1525,9 @@ func TestEnabledAndFeatureFlagInteraction(t *testing.T) { } // Feature flag not enabled - tool should be excluded despite Enabled returning true - reg1 := NewBuilder(). + reg1 := mustBuild(t, NewBuilder(). SetTools([]ServerTool{tool}). - WithToolsets([]string{"all"}). - Build() + WithToolsets([]string{"all"})) available1 := reg1.AvailableTools(context.Background()) if len(available1) != 0 { t.Error("Tool should be excluded when feature flag is not enabled") @@ -1435,11 +1537,10 @@ func TestEnabledAndFeatureFlagInteraction(t *testing.T) { checker := func(_ context.Context, flag string) (bool, error) { return flag == "my_feature", nil } - reg2 := NewBuilder(). + reg2 := mustBuild(t, NewBuilder(). SetTools([]ServerTool{tool}). WithToolsets([]string{"all"}). - WithFeatureChecker(checker). - Build() + WithFeatureChecker(checker)) available2 := reg2.AvailableTools(context.Background()) if len(available2) != 1 { t.Error("Tool should be included when both Enabled and feature flag pass") @@ -1449,11 +1550,10 @@ func TestEnabledAndFeatureFlagInteraction(t *testing.T) { tool.Enabled = func(_ context.Context) (bool, error) { return false, nil } - reg3 := NewBuilder(). + reg3 := mustBuild(t, NewBuilder(). SetTools([]ServerTool{tool}). WithToolsets([]string{"all"}). - WithFeatureChecker(checker). - Build() + WithFeatureChecker(checker)) available3 := reg3.AvailableTools(context.Background()) if len(available3) != 0 { t.Error("Tool should be excluded when Enabled returns false") @@ -1471,11 +1571,10 @@ func TestEnabledAndBuilderFilterInteraction(t *testing.T) { return false, nil } - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools([]ServerTool{tool}). WithToolsets([]string{"all"}). - WithFilter(filter). - Build() + WithFilter(filter)) available := reg.AvailableTools(context.Background()) if len(available) != 0 { @@ -1499,12 +1598,11 @@ func TestAllFiltersInteraction(t *testing.T) { } // All conditions pass - tool should be included - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools([]ServerTool{tool}). WithToolsets([]string{"all"}). WithFeatureChecker(checker). - WithFilter(filter). - Build() + WithFilter(filter)) available := reg.AvailableTools(context.Background()) if len(available) != 1 { @@ -1516,12 +1614,11 @@ func TestAllFiltersInteraction(t *testing.T) { return false, nil } - reg2 := NewBuilder(). + reg2 := mustBuild(t, NewBuilder(). SetTools([]ServerTool{tool}). WithToolsets([]string{"all"}). WithFeatureChecker(checker). - WithFilter(filterFalse). - Build() + WithFilter(filterFalse)) available2 := reg2.AvailableTools(context.Background()) if len(available2) != 0 { @@ -1540,11 +1637,10 @@ func TestFilteredTools(t *testing.T) { return tool.Tool.Name == "tool1", nil } - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets([]string{"all"}). - WithFilter(filter). - Build() + WithFilter(filter)) filtered, err := reg.FilteredTools(context.Background()) if err != nil { @@ -1567,11 +1663,10 @@ func TestFilteredToolsMatchesAvailableTools(t *testing.T) { mockTool("tool3", "toolset2", true), } - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets([]string{"toolset1"}). - WithReadOnly(true). - Build() + WithReadOnly(true)) ctx := context.Background() filtered, err := reg.FilteredTools(ctx) @@ -1621,13 +1716,12 @@ func TestFilteringOrder(t *testing.T) { return true, nil } - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools([]ServerTool{tool}). WithToolsets([]string{"all"}). WithReadOnly(true). // This will exclude the tool (it's not read-only) WithFeatureChecker(checker). - WithFilter(filter). - Build() + WithFilter(filter)) _ = reg.AvailableTools(context.Background()) @@ -1643,3 +1737,98 @@ func TestFilteringOrder(t *testing.T) { } } } + +func TestForMCPRequest_ToolsCall_FeatureFlaggedVariants(t *testing.T) { + // Simulate the get_job_logs scenario: two tools with the same name but different feature flags + // - "get_job_logs" with FeatureFlagDisable (available when flag is OFF) + // - "get_job_logs" with FeatureFlagEnable (available when flag is ON) + tools := []ServerTool{ + mockToolWithFlags("get_job_logs", "actions", true, "", "consolidated_flag"), // disabled when flag is ON + mockToolWithFlags("get_job_logs", "actions", true, "consolidated_flag", ""), // enabled when flag is ON + mockTool("other_tool", "actions", true), + } + + // Test 1: Flag is OFF - first tool variant should be available + regFlagOff := mustBuild(t, NewBuilder(). + SetTools(tools). + WithToolsets([]string{"all"})) + filteredOff := regFlagOff.ForMCPRequest(MCPMethodToolsCall, "get_job_logs") + availableOff := filteredOff.AvailableTools(context.Background()) + if len(availableOff) != 1 { + t.Fatalf("Flag OFF: Expected 1 tool, got %d", len(availableOff)) + } + if availableOff[0].FeatureFlagDisable != "consolidated_flag" { + t.Errorf("Flag OFF: Expected tool with FeatureFlagDisable, got FeatureFlagEnable=%q, FeatureFlagDisable=%q", + availableOff[0].FeatureFlagEnable, availableOff[0].FeatureFlagDisable) + } + + // Test 2: Flag is ON - second tool variant should be available + checker := func(_ context.Context, flag string) (bool, error) { + return flag == "consolidated_flag", nil + } + regFlagOn := mustBuild(t, NewBuilder(). + SetTools(tools). + WithToolsets([]string{"all"}). + WithFeatureChecker(checker)) + filteredOn := regFlagOn.ForMCPRequest(MCPMethodToolsCall, "get_job_logs") + availableOn := filteredOn.AvailableTools(context.Background()) + if len(availableOn) != 1 { + t.Fatalf("Flag ON: Expected 1 tool, got %d", len(availableOn)) + } + if availableOn[0].FeatureFlagEnable != "consolidated_flag" { + t.Errorf("Flag ON: Expected tool with FeatureFlagEnable, got FeatureFlagEnable=%q, FeatureFlagDisable=%q", + availableOn[0].FeatureFlagEnable, availableOn[0].FeatureFlagDisable) + } +} + +// TestWithTools_DeprecatedAliasAndFeatureFlag tests that deprecated aliases work correctly +// when the old tool is controlled by a feature flag. This covers the scenario where: +// - Old tool "old_tool" has FeatureFlagDisable="my_flag" (available when flag is OFF) +// - New tool "new_tool" has FeatureFlagEnable="my_flag" (available when flag is ON) +// - Deprecated alias maps "old_tool" -> "new_tool" +// - User specifies --tools=old_tool +// Expected behavior: +// - Flag OFF: old_tool should be available (not the new_tool via alias) +// - Flag ON: new_tool should be available (via alias resolution) +func TestWithTools_DeprecatedAliasAndFeatureFlag(t *testing.T) { + oldTool := mockToolWithFlags("old_tool", "actions", true, "", "my_flag") + newTool := mockToolWithFlags("new_tool", "actions", true, "my_flag", "") + tools := []ServerTool{oldTool, newTool} + + deprecatedAliases := map[string]string{ + "old_tool": "new_tool", + } + + // Test 1: Flag OFF - old_tool should be available via direct name match + // (not via alias resolution to new_tool, since old_tool still exists) + regFlagOff := mustBuild(t, NewBuilder(). + SetTools(tools). + WithDeprecatedAliases(deprecatedAliases). + WithToolsets([]string{}). // No toolsets enabled + WithTools([]string{"old_tool"})) // Explicitly request old tool + availableOff := regFlagOff.AvailableTools(context.Background()) + if len(availableOff) != 1 { + t.Fatalf("Flag OFF: Expected 1 tool, got %d", len(availableOff)) + } + if availableOff[0].Tool.Name != "old_tool" { + t.Errorf("Flag OFF: Expected old_tool, got %s", availableOff[0].Tool.Name) + } + + // Test 2: Flag ON - new_tool should be available via alias resolution + checker := func(_ context.Context, flag string) (bool, error) { + return flag == "my_flag", nil + } + regFlagOn := mustBuild(t, NewBuilder(). + SetTools(tools). + WithDeprecatedAliases(deprecatedAliases). + WithToolsets([]string{}). // No toolsets enabled + WithTools([]string{"old_tool"}). // Request old tool name + WithFeatureChecker(checker)) + availableOn := regFlagOn.AvailableTools(context.Background()) + if len(availableOn) != 1 { + t.Fatalf("Flag ON: Expected 1 tool, got %d", len(availableOn)) + } + if availableOn[0].Tool.Name != "new_tool" { + t.Errorf("Flag ON: Expected new_tool (via alias), got %s", availableOn[0].Tool.Name) + } +} diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 362ee2643..752a4c2bd 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -31,6 +31,9 @@ type ToolsetMetadata struct { // Use the base name without size suffix, e.g., "repo" not "repo-16". // See https://primer.style/foundations/icons for available icons. Icon string + // InstructionsFunc optionally returns instructions for this toolset. + // It receives the inventory so it can check what other toolsets are enabled. + InstructionsFunc func(inv *Inventory) string } // Icons returns MCP Icon objects for this toolset, or nil if no icon is set. @@ -70,6 +73,15 @@ type ServerTool struct { // The context carries request-scoped information for the consumer to use. // Returns (enabled, error). On error, the tool should be treated as disabled. Enabled func(ctx context.Context) (bool, error) + + // RequiredScopes specifies the minimum OAuth scopes required for this tool. + // These are the scopes that must be present for the tool to function. + RequiredScopes []string + + // AcceptedScopes specifies all OAuth scopes that can be used with this tool. + // This includes the required scopes plus any higher-level scopes that provide + // the necessary permissions due to scope hierarchy. + AcceptedScopes []string } // IsReadOnly returns true if this tool is marked as read-only via annotations. diff --git a/pkg/raw/raw_mock.go b/pkg/raw/raw_mock.go deleted file mode 100644 index 30c7759d3..000000000 --- a/pkg/raw/raw_mock.go +++ /dev/null @@ -1,20 +0,0 @@ -package raw - -import "github.com/migueleliasweb/go-github-mock/src/mock" - -var GetRawReposContentsByOwnerByRepoByPath mock.EndpointPattern = mock.EndpointPattern{ - Pattern: "/{owner}/{repo}/HEAD/{path:.*}", - Method: "GET", -} -var GetRawReposContentsByOwnerByRepoByBranchByPath mock.EndpointPattern = mock.EndpointPattern{ - Pattern: "/{owner}/{repo}/refs/heads/{branch}/{path:.*}", - Method: "GET", -} -var GetRawReposContentsByOwnerByRepoByTagByPath mock.EndpointPattern = mock.EndpointPattern{ - Pattern: "/{owner}/{repo}/refs/tags/{tag}/{path:.*}", - Method: "GET", -} -var GetRawReposContentsByOwnerByRepoBySHAByPath mock.EndpointPattern = mock.EndpointPattern{ - Pattern: "/{owner}/{repo}/{sha}/{path:.*}", - Method: "GET", -} diff --git a/pkg/scopes/fetcher.go b/pkg/scopes/fetcher.go new file mode 100644 index 000000000..48e000179 --- /dev/null +++ b/pkg/scopes/fetcher.go @@ -0,0 +1,125 @@ +package scopes + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// OAuthScopesHeader is the HTTP response header containing the token's OAuth scopes. +const OAuthScopesHeader = "X-OAuth-Scopes" + +// DefaultFetchTimeout is the default timeout for scope fetching requests. +const DefaultFetchTimeout = 10 * time.Second + +// FetcherOptions configures the scope fetcher. +type FetcherOptions struct { + // HTTPClient is the HTTP client to use for requests. + // If nil, a default client with DefaultFetchTimeout is used. + HTTPClient *http.Client + + // APIHost is the GitHub API host (e.g., "https://api.github.com"). + // Defaults to "https://api.github.com" if empty. + APIHost string +} + +// Fetcher retrieves token scopes from GitHub's API. +// It uses an HTTP HEAD request to minimize bandwidth since we only need headers. +type Fetcher struct { + client *http.Client + apiHost string +} + +// NewFetcher creates a new scope fetcher with the given options. +func NewFetcher(opts FetcherOptions) *Fetcher { + client := opts.HTTPClient + if client == nil { + client = &http.Client{Timeout: DefaultFetchTimeout} + } + + apiHost := opts.APIHost + if apiHost == "" { + apiHost = "https://api.github.com" + } + + return &Fetcher{ + client: client, + apiHost: apiHost, + } +} + +// FetchTokenScopes retrieves the OAuth scopes for a token by making an HTTP HEAD +// request to the GitHub API and parsing the X-OAuth-Scopes header. +// +// Returns: +// - []string: List of scopes (empty if no scopes or fine-grained PAT) +// - error: Any HTTP or parsing error +// +// Note: Fine-grained PATs don't return the X-OAuth-Scopes header, so an empty +// slice is returned for those tokens. +func (f *Fetcher) FetchTokenScopes(ctx context.Context, token string) ([]string, error) { + // Use a lightweight endpoint that requires authentication + endpoint, err := url.JoinPath(f.apiHost, "/") + if err != nil { + return nil, fmt.Errorf("failed to construct API URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodHead, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := f.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch scopes: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("invalid or expired token") + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return ParseScopeHeader(resp.Header.Get(OAuthScopesHeader)), nil +} + +// ParseScopeHeader parses the X-OAuth-Scopes header value into a list of scopes. +// The header contains comma-separated scope names. +// Returns an empty slice for empty or missing header. +func ParseScopeHeader(header string) []string { + if header == "" { + return []string{} + } + + parts := strings.Split(header, ",") + scopes := make([]string, 0, len(parts)) + for _, part := range parts { + scope := strings.TrimSpace(part) + if scope != "" { + scopes = append(scopes, scope) + } + } + return scopes +} + +// FetchTokenScopes is a convenience function that creates a default fetcher +// and fetches the token scopes. +func FetchTokenScopes(ctx context.Context, token string) ([]string, error) { + return NewFetcher(FetcherOptions{}).FetchTokenScopes(ctx, token) +} + +// FetchTokenScopesWithHost is a convenience function that creates a fetcher +// for a specific API host and fetches the token scopes. +func FetchTokenScopesWithHost(ctx context.Context, token, apiHost string) ([]string, error) { + return NewFetcher(FetcherOptions{APIHost: apiHost}).FetchTokenScopes(ctx, token) +} diff --git a/pkg/scopes/fetcher_test.go b/pkg/scopes/fetcher_test.go new file mode 100644 index 000000000..13feab5b0 --- /dev/null +++ b/pkg/scopes/fetcher_test.go @@ -0,0 +1,214 @@ +package scopes + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseScopeHeader(t *testing.T) { + tests := []struct { + name string + header string + expected []string + }{ + { + name: "empty header", + header: "", + expected: []string{}, + }, + { + name: "single scope", + header: "repo", + expected: []string{"repo"}, + }, + { + name: "multiple scopes", + header: "repo, user, gist", + expected: []string{"repo", "user", "gist"}, + }, + { + name: "scopes with extra whitespace", + header: " repo , user , gist ", + expected: []string{"repo", "user", "gist"}, + }, + { + name: "scopes without spaces", + header: "repo,user,gist", + expected: []string{"repo", "user", "gist"}, + }, + { + name: "scopes with colons", + header: "read:org, write:org, admin:org", + expected: []string{"read:org", "write:org", "admin:org"}, + }, + { + name: "empty parts are filtered", + header: "repo,,gist", + expected: []string{"repo", "gist"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseScopeHeader(tt.header) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFetcher_FetchTokenScopes(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + expectedScopes []string + expectError bool + errorContains string + }{ + { + name: "successful fetch with multiple scopes", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-OAuth-Scopes", "repo, user, gist") + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{"repo", "user", "gist"}, + expectError: false, + }, + { + name: "successful fetch with single scope", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-OAuth-Scopes", "repo") + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{"repo"}, + expectError: false, + }, + { + name: "fine-grained PAT returns empty scopes", + handler: func(w http.ResponseWriter, _ *http.Request) { + // Fine-grained PATs don't return X-OAuth-Scopes + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{}, + expectError: false, + }, + { + name: "unauthorized token", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }, + expectError: true, + errorContains: "invalid or expired token", + }, + { + name: "server error", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + expectError: true, + errorContains: "unexpected status code: 500", + }, + { + name: "verifies authorization header is set", + handler: func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("X-OAuth-Scopes", "repo") + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{"repo"}, + expectError: false, + }, + { + name: "verifies request method is HEAD", + handler: func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodHead { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.Header().Set("X-OAuth-Scopes", "repo") + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{"repo"}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(tt.handler) + defer server.Close() + + fetcher := NewFetcher(FetcherOptions{ + APIHost: server.URL, + }) + + scopes, err := fetcher.FetchTokenScopes(context.Background(), "test-token") + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedScopes, scopes) + } + }) + } +} + +func TestFetcher_DefaultOptions(t *testing.T) { + fetcher := NewFetcher(FetcherOptions{}) + + // Verify default API host is set + assert.Equal(t, "https://api.github.com", fetcher.apiHost) + + // Verify default HTTP client is set with timeout + assert.NotNil(t, fetcher.client) + assert.Equal(t, DefaultFetchTimeout, fetcher.client.Timeout) +} + +func TestFetcher_CustomHTTPClient(t *testing.T) { + customClient := &http.Client{Timeout: 5 * time.Second} + + fetcher := NewFetcher(FetcherOptions{ + HTTPClient: customClient, + }) + + assert.Equal(t, customClient, fetcher.client) +} + +func TestFetcher_CustomAPIHost(t *testing.T) { + fetcher := NewFetcher(FetcherOptions{ + APIHost: "https://api.github.enterprise.com", + }) + + assert.Equal(t, "https://api.github.enterprise.com", fetcher.apiHost) +} + +func TestFetcher_ContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + fetcher := NewFetcher(FetcherOptions{ + APIHost: server.URL, + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err := fetcher.FetchTokenScopes(ctx, "test-token") + require.Error(t, err) +} diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go new file mode 100644 index 000000000..a9b06e988 --- /dev/null +++ b/pkg/scopes/scopes.go @@ -0,0 +1,194 @@ +package scopes + +import "sort" + +// Scope represents a GitHub OAuth scope. +// These constants define all OAuth scopes used by the GitHub MCP server tools. +// See https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps +type Scope string + +const ( + // NoScope indicates no scope is required (public access). + NoScope Scope = "" + + // Repo grants full control of private repositories + Repo Scope = "repo" + + // PublicRepo grants access to public repositories + PublicRepo Scope = "public_repo" + + // ReadOrg grants read-only access to organization membership, teams, and projects + ReadOrg Scope = "read:org" + + // WriteOrg grants write access to organization membership and teams + WriteOrg Scope = "write:org" + + // AdminOrg grants full control of organizations and teams + AdminOrg Scope = "admin:org" + + // Gist grants write access to gists + Gist Scope = "gist" + + // Notifications grants access to notifications + Notifications Scope = "notifications" + + // ReadProject grants read-only access to projects + ReadProject Scope = "read:project" + + // Project grants full control of projects + Project Scope = "project" + + // SecurityEvents grants read and write access to security events + SecurityEvents Scope = "security_events" + + // User grants read/write access to profile info + User Scope = "user" + + // ReadUser grants read-only access to profile info + ReadUser Scope = "read:user" + + // UserEmail grants read access to user email addresses + UserEmail Scope = "user:email" + + // ReadPackages grants read access to packages + ReadPackages Scope = "read:packages" + + // WritePackages grants write access to packages + WritePackages Scope = "write:packages" +) + +// ScopeHierarchy defines parent-child relationships between scopes. +// A parent scope implicitly grants access to all child scopes. +// For example, "repo" grants access to "public_repo" and "security_events". +var ScopeHierarchy = map[Scope][]Scope{ + Repo: {PublicRepo, SecurityEvents}, + AdminOrg: {WriteOrg, ReadOrg}, + WriteOrg: {ReadOrg}, + Project: {ReadProject}, + WritePackages: {ReadPackages}, + User: {ReadUser, UserEmail}, +} + +// ScopeSet represents a set of OAuth scopes. +type ScopeSet map[Scope]bool + +// NewScopeSet creates a new ScopeSet from the given scopes. +func NewScopeSet(scopes ...Scope) ScopeSet { + set := make(ScopeSet) + for _, scope := range scopes { + set[scope] = true + } + return set +} + +// ToSlice converts a ScopeSet to a slice of Scope values. +func (s ScopeSet) ToSlice() []Scope { + scopes := make([]Scope, 0, len(s)) + for scope := range s { + scopes = append(scopes, scope) + } + // Sort for deterministic output + sort.Slice(scopes, func(i, j int) bool { + return scopes[i] < scopes[j] + }) + return scopes +} + +// ToStringSlice converts a ScopeSet to a slice of string values. +// The returned slice is sorted for deterministic output. +func (s ScopeSet) ToStringSlice() []string { + scopes := make([]string, 0, len(s)) + for scope := range s { + scopes = append(scopes, string(scope)) + } + sort.Strings(scopes) + return scopes +} + +// ToStringSlice converts a slice of Scopes to a slice of strings. +func ToStringSlice(scopes ...Scope) []string { + result := make([]string, len(scopes)) + for i, scope := range scopes { + result[i] = string(scope) + } + return result +} + +// ExpandScopes takes a list of required scopes and returns all accepted scopes +// including parent scopes from the hierarchy. +// For example, if "public_repo" is required, "repo" is also accepted since +// having the "repo" scope grants access to "public_repo". +// The returned slice is sorted for deterministic output. +func ExpandScopes(required ...Scope) []string { + if len(required) == 0 { + return nil + } + + accepted := make(map[string]bool) + + // Add required scopes + for _, scope := range required { + accepted[string(scope)] = true + } + + // Add parent scopes that grant access to required scopes + for parent, children := range ScopeHierarchy { + for _, child := range children { + if accepted[string(child)] { + accepted[string(parent)] = true + } + } + } + + // Convert to slice and sort for deterministic output + result := make([]string, 0, len(accepted)) + for scope := range accepted { + result = append(result, scope) + } + sort.Strings(result) + return result +} + +// expandScopeSet returns a set of all scopes granted by the given scopes, +// including child scopes from the hierarchy. +// For example, if "repo" is provided, the result includes "repo", "public_repo", +// and "security_events" since "repo" grants access to those child scopes. +func expandScopeSet(scopes []string) map[string]bool { + expanded := make(map[string]bool, len(scopes)) + for _, scope := range scopes { + expanded[scope] = true + // Add child scopes granted by this scope + if children, ok := ScopeHierarchy[Scope(scope)]; ok { + for _, child := range children { + expanded[string(child)] = true + } + } + } + return expanded +} + +// HasRequiredScopes checks if tokenScopes satisfy the acceptedScopes requirement. +// A tool's acceptedScopes includes both the required scopes AND parent scopes +// that implicitly grant the required permissions (via ExpandScopes). +// +// For PAT filtering: if ANY of the acceptedScopes are granted by the token +// (directly or via scope hierarchy), the tool should be visible. +// +// Returns true if the tool should be visible to the token holder. +func HasRequiredScopes(tokenScopes []string, acceptedScopes []string) bool { + // No scopes required = always allowed + if len(acceptedScopes) == 0 { + return true + } + + // Expand token scopes to include child scopes they grant + grantedScopes := expandScopeSet(tokenScopes) + + // Check if any accepted scope is granted by the token + for _, accepted := range acceptedScopes { + if grantedScopes[accepted] { + return true + } + } + return false +} diff --git a/pkg/scopes/scopes_test.go b/pkg/scopes/scopes_test.go new file mode 100644 index 000000000..b8e0d8e42 --- /dev/null +++ b/pkg/scopes/scopes_test.go @@ -0,0 +1,332 @@ +package scopes + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExpandScopes(t *testing.T) { + tests := []struct { + name string + required []Scope + expected []string + }{ + { + name: "nil returns nil", + required: nil, + expected: nil, + }, + { + name: "empty returns nil", + required: []Scope{}, + expected: nil, + }, + { + name: "repo scope returns just repo", + required: []Scope{Repo}, + expected: []string{"repo"}, + }, + { + name: "public_repo also accepts repo (parent)", + required: []Scope{PublicRepo}, + expected: []string{"public_repo", "repo"}, + }, + { + name: "security_events also accepts repo (parent)", + required: []Scope{SecurityEvents}, + expected: []string{"repo", "security_events"}, + }, + { + name: "read:org also accepts write:org and admin:org (parents)", + required: []Scope{ReadOrg}, + expected: []string{"admin:org", "read:org", "write:org"}, + }, + { + name: "write:org also accepts admin:org (parent)", + required: []Scope{WriteOrg}, + expected: []string{"admin:org", "write:org"}, + }, + { + name: "admin:org returns just admin:org (no parent)", + required: []Scope{AdminOrg}, + expected: []string{"admin:org"}, + }, + { + name: "read:project also accepts project (parent)", + required: []Scope{ReadProject}, + expected: []string{"project", "read:project"}, + }, + { + name: "project returns just project (no parent)", + required: []Scope{Project}, + expected: []string{"project"}, + }, + { + name: "gist returns just gist (no parent)", + required: []Scope{Gist}, + expected: []string{"gist"}, + }, + { + name: "notifications returns just notifications (no parent)", + required: []Scope{Notifications}, + expected: []string{"notifications"}, + }, + { + name: "read:packages also accepts write:packages (parent)", + required: []Scope{ReadPackages}, + expected: []string{"read:packages", "write:packages"}, + }, + { + name: "read:user also accepts user (parent)", + required: []Scope{ReadUser}, + expected: []string{"read:user", "user"}, + }, + { + name: "multiple scopes combine correctly", + required: []Scope{PublicRepo, ReadOrg}, + expected: []string{"admin:org", "public_repo", "read:org", "repo", "write:org"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExpandScopes(tt.required...) + + // Sort both for consistent comparison + if result != nil { + sort.Strings(result) + } + if tt.expected != nil { + sort.Strings(tt.expected) + } + + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestToStringSlice(t *testing.T) { + tests := []struct { + name string + scopes []Scope + expected []string + }{ + { + name: "empty returns empty", + scopes: []Scope{}, + expected: []string{}, + }, + { + name: "single scope", + scopes: []Scope{Repo}, + expected: []string{"repo"}, + }, + { + name: "multiple scopes", + scopes: []Scope{Repo, Gist, ReadOrg}, + expected: []string{"repo", "gist", "read:org"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ToStringSlice(tt.scopes...) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestScopeHierarchy(t *testing.T) { + // Verify the hierarchy is correctly defined + assert.Contains(t, ScopeHierarchy[Repo], PublicRepo) + assert.Contains(t, ScopeHierarchy[Repo], SecurityEvents) + assert.Contains(t, ScopeHierarchy[AdminOrg], WriteOrg) + assert.Contains(t, ScopeHierarchy[AdminOrg], ReadOrg) + assert.Contains(t, ScopeHierarchy[WriteOrg], ReadOrg) + assert.Contains(t, ScopeHierarchy[Project], ReadProject) + assert.Contains(t, ScopeHierarchy[WritePackages], ReadPackages) + assert.Contains(t, ScopeHierarchy[User], ReadUser) + assert.Contains(t, ScopeHierarchy[User], UserEmail) +} + +func TestExpandScopeSet(t *testing.T) { + tests := []struct { + name string + scopes []string + expected map[string]bool + }{ + { + name: "empty scopes", + scopes: []string{}, + expected: map[string]bool{}, + }, + { + name: "repo expands to include public_repo and security_events", + scopes: []string{"repo"}, + expected: map[string]bool{ + "repo": true, + "public_repo": true, + "security_events": true, + }, + }, + { + name: "admin:org expands to include write:org and read:org", + scopes: []string{"admin:org"}, + expected: map[string]bool{ + "admin:org": true, + "write:org": true, + "read:org": true, + }, + }, + { + name: "write:org expands to include read:org", + scopes: []string{"write:org"}, + expected: map[string]bool{ + "write:org": true, + "read:org": true, + }, + }, + { + name: "user expands to include read:user and user:email", + scopes: []string{"user"}, + expected: map[string]bool{ + "user": true, + "read:user": true, + "user:email": true, + }, + }, + { + name: "scope without children stays as-is", + scopes: []string{"gist"}, + expected: map[string]bool{ + "gist": true, + }, + }, + { + name: "multiple scopes combine correctly", + scopes: []string{"repo", "gist"}, + expected: map[string]bool{ + "repo": true, + "public_repo": true, + "security_events": true, + "gist": true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := expandScopeSet(tt.scopes) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHasRequiredScopes(t *testing.T) { + tests := []struct { + name string + tokenScopes []string + acceptedScopes []string + expected bool + }{ + { + name: "no accepted scopes - always allowed", + tokenScopes: []string{}, + acceptedScopes: []string{}, + expected: true, + }, + { + name: "nil accepted scopes - always allowed", + tokenScopes: []string{"repo"}, + acceptedScopes: nil, + expected: true, + }, + { + name: "token has exact required scope", + tokenScopes: []string{"repo"}, + acceptedScopes: []string{"repo"}, + expected: true, + }, + { + name: "token has parent scope that grants access", + tokenScopes: []string{"repo"}, + acceptedScopes: []string{"public_repo"}, + expected: true, + }, + { + name: "token has parent scope for security_events", + tokenScopes: []string{"repo"}, + acceptedScopes: []string{"security_events"}, + expected: true, + }, + { + name: "token has admin:org which grants read:org", + tokenScopes: []string{"admin:org"}, + acceptedScopes: []string{"read:org"}, + expected: true, + }, + { + name: "token has write:org which grants read:org", + tokenScopes: []string{"write:org"}, + acceptedScopes: []string{"read:org"}, + expected: true, + }, + { + name: "token missing required scope", + tokenScopes: []string{"gist"}, + acceptedScopes: []string{"repo"}, + expected: false, + }, + { + name: "token has child but not parent - fails", + tokenScopes: []string{"public_repo"}, + acceptedScopes: []string{"repo"}, + expected: false, + }, + { + name: "multiple token scopes - one matches", + tokenScopes: []string{"gist", "repo"}, + acceptedScopes: []string{"public_repo"}, + expected: true, + }, + { + name: "multiple accepted scopes - token has one", + tokenScopes: []string{"repo"}, + acceptedScopes: []string{"repo", "admin:org"}, + expected: true, + }, + { + name: "empty token scopes - fails when scopes required", + tokenScopes: []string{}, + acceptedScopes: []string{"repo"}, + expected: false, + }, + { + name: "user scope grants read:user", + tokenScopes: []string{"user"}, + acceptedScopes: []string{"read:user"}, + expected: true, + }, + { + name: "user scope grants user:email", + tokenScopes: []string{"user"}, + acceptedScopes: []string{"user:email"}, + expected: true, + }, + { + name: "write:packages grants read:packages", + tokenScopes: []string{"write:packages"}, + acceptedScopes: []string{"read:packages"}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := HasRequiredScopes(tt.tokenScopes, tt.acceptedScopes) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/tooldiscovery/search.go b/pkg/tooldiscovery/search.go new file mode 100644 index 000000000..e7adc029b --- /dev/null +++ b/pkg/tooldiscovery/search.go @@ -0,0 +1,314 @@ +package tooldiscovery + +import ( + "sort" + "strings" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/lithammer/fuzzysearch/fuzzy" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type SearchResult struct { + Tool mcp.Tool `json:"tool"` + Score float64 `json:"score"` + MatchedIn []string `json:"matchedIn"` // Signals that contributed to scoring (e.g. name:token, description, parameter:token). +} + +const ( + DefaultMaxSearchResults = 3 + + // Scoring weights used by scoreTool. + substringMatchScore = 5 + exactTokensMatchScore = 2.5 + descriptionMatchScore = 2 + prefixMatchScore = 1.5 + parameterMatchScore = 1 +) + +// SearchOptions configures search behavior. +type SearchOptions struct { + MaxResults int `json:"maxResults"` // Maximum number of results to return (default: 3) +} + +// Search returns the most relevant tools for a free-text query. +// +// Prefer using SearchTools and passing an explicit tool list. This function is +// kept for API compatibility and currently searches an empty tool set. +func Search(query string, options ...SearchOptions) ([]SearchResult, error) { + return SearchTools(nil, query, options...) +} + +// SearchTools is like Search, but searches across the provided tool list. +// +// Matching uses a weighted combination of: +// - tool name matches (strongest) +// - description matches +// - input parameter name matches (JSON schema property names) +// - fuzzy similarity as a tie-breaker +// +// Empty or whitespace-only queries return (nil, nil). +func SearchTools(tools []mcp.Tool, query string, options ...SearchOptions) ([]SearchResult, error) { + maxResults := getMaxResults(options) + + query = strings.TrimSpace(query) + if query == "" { + return nil, nil + } + + queryLower := strings.ToLower(query) + queryTokens := strings.Fields(queryLower) + normalizedQueryCompact := strings.ReplaceAll(strings.ReplaceAll(queryLower, " ", ""), "_", "") + + results := make([]SearchResult, 0, len(tools)) + for _, tool := range tools { + score, matchedIn := scoreTool(tool, queryLower, queryTokens, normalizedQueryCompact) + results = append(results, SearchResult{ + Tool: tool, + Score: score, + MatchedIn: matchedIn, + }) + } + + sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score }) + + // Filter out low-relevance results + const minScore = 1.0 + filtered := results[:0] + for _, r := range results { + if r.Score > minScore { + filtered = append(filtered, r) + } + } + results = filtered + + // Limit results + if len(results) > maxResults { + results = results[:maxResults] + } + + return results, nil +} + +// scoreTool assigns a relevance score to a tool for the given query. +// +// It combines several signals (substrings, token coverage, and similarity) from: +// - tool name +// - tool description +// - input parameter names (schema property names) +// +// MatchedIn records which signals contributed to the score for debugging/tuning. +func scoreTool( + tool mcp.Tool, + queryLower string, + queryTokens []string, + normalizedQueryCompact string, +) (score float64, matchedIn []string) { + nameLower := strings.ToLower(tool.Name) + descLower := strings.ToLower(tool.Description) + + normalizedNameCompact := strings.ReplaceAll(nameLower, "_", "") + nameTokens := splitTokens(nameLower) + propertyNames := lowerInputPropertyNames(tool.InputSchema) + + matches := newMatchTracker(3) + score = 0.0 + + // Strong boosts for direct substring matches + if strings.Contains(nameLower, queryLower) { + score += substringMatchScore + matches.Add("name:substring") + } + if strings.HasPrefix(nameLower, queryLower) { + score += prefixMatchScore + matches.Add("name:prefix") + } + if normalizedNameCompact == normalizedQueryCompact && len(queryTokens) > 1 { + score += exactTokensMatchScore + matches.Add("name:exact-tokens") + } + if strings.Contains(descLower, queryLower) { + score += descriptionMatchScore + matches.Add("description") + } + + for _, prop := range propertyNames { + if strings.Contains(prop, queryLower) { + score += parameterMatchScore + matches.Add("parameter") + } + } + + matchedTokens := make(map[string]struct{}) + + // Token-level matches for multi-word queries + for _, token := range queryTokens { + if strings.Contains(nameLower, token) { + score++ + matchedTokens[token] = struct{}{} + matches.Add("name:token") + } else if strings.Contains(descLower, token) { + score += 0.6 + matchedTokens[token] = struct{}{} + matches.Add("description:token") + } + + for _, prop := range propertyNames { + if strings.Contains(prop, token) { + // Only credit the first parameter match per token to avoid double-counting + score += 0.4 + matchedTokens[token] = struct{}{} + matches.Add("parameter:token") + break + } + } + } + + tokenCoverage := float64(len(matchedTokens)) + score += tokenCoverage * 0.8 + if len(queryTokens) > 1 && len(matchedTokens) == len(queryTokens) { + score += 2 // bonus when all tokens are matched somewhere + } + + // Prefer names that cover query tokens directly, with fewer extra tokens + nameTokenMatches := 0 + for _, qt := range queryTokens { + for _, nt := range nameTokens { + if strings.Contains(nt, qt) { + nameTokenMatches++ + break + } + } + } + if nameTokenMatches == len(queryTokens) { + score += 4.0 // all tokens present in name tokens + if len(nameTokens) == len(queryTokens) { + score += 2.0 // exact token count match (e.g., issue_write vs sub_issue_write) + } + } + extraTokens := len(nameTokens) - nameTokenMatches + if extraTokens > 0 { + score -= float64(extraTokens) * 0.5 // stronger penalty for extra unrelated tokens + } + + // Similarity scores to soften ordering among close matches + nameSim := normalizedSimilarity(nameLower, queryLower) + descSim := normalizedSimilarity(descLower, queryLower) + + var propSim float64 + for _, prop := range propertyNames { + if sim := normalizedSimilarity(prop, queryLower); sim > propSim { + propSim = sim + } + } + + searchText := nameLower + " " + descLower + if len(propertyNames) > 0 { + searchText += " " + strings.Join(propertyNames, " ") + } + fuzzySim := normalizedSimilarity(searchText, queryLower) + + score += nameSim * 2 + score += descSim * 0.8 + score += propSim * 0.6 + score += fuzzySim * 0.5 + + return score, matches.List() +} + +func getMaxResults(options []SearchOptions) int { + maxResults := DefaultMaxSearchResults + if len(options) > 0 && options[0].MaxResults > 0 { + maxResults = options[0].MaxResults + } + return maxResults +} + +func lowerInputPropertyNames(inputSchema any) []string { + if inputSchema == nil { + return nil + } + + // From the server, this is commonly a *jsonschema.Schema. + if schema, ok := inputSchema.(*jsonschema.Schema); ok { + if len(schema.Properties) == 0 { + return nil + } + out := make([]string, 0, len(schema.Properties)) + for prop := range schema.Properties { + out = append(out, strings.ToLower(prop)) + } + return out + } + + // From the client (or when unmarshaled), schemas arrive as map[string]any. + if schema, ok := inputSchema.(map[string]any); ok { + propsAny, ok := schema["properties"] + if !ok { + return nil + } + props, ok := propsAny.(map[string]any) + if !ok || len(props) == 0 { + return nil + } + out := make([]string, 0, len(props)) + for prop := range props { + out = append(out, strings.ToLower(prop)) + } + return out + } + + return nil +} + +type matchTracker struct { + list []string + seen map[string]struct{} +} + +func newMatchTracker(capacity int) *matchTracker { + return &matchTracker{ + list: make([]string, 0, capacity), + seen: make(map[string]struct{}, capacity), + } +} + +func (m *matchTracker) Add(part string) { + if _, ok := m.seen[part]; ok { + return + } + m.seen[part] = struct{}{} + m.list = append(m.list, part) +} + +func (m *matchTracker) List() []string { + return m.list +} + +func normalizedSimilarity(a, b string) float64 { + if len(a) == 0 || len(b) == 0 { + return 0 + } + + distance := fuzzy.LevenshteinDistance(a, b) + maxLen := len(a) + if len(b) > maxLen { + maxLen = len(b) + } + + similarity := 1 - (float64(distance) / float64(maxLen)) + if similarity < 0 { + return 0 + } + + return similarity +} + +func splitTokens(s string) []string { + if s == "" { + return nil + } + return strings.FieldsFunc(s, func(r rune) bool { + return r == '_' || r == '-' || r == ' ' + }) +} diff --git a/pkg/tooldiscovery/search_test.go b/pkg/tooldiscovery/search_test.go new file mode 100644 index 000000000..79d6fe8dd --- /dev/null +++ b/pkg/tooldiscovery/search_test.go @@ -0,0 +1,57 @@ +package tooldiscovery + +import ( + "testing" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" +) + +func TestSearchTools_EmptyQueryReturnsNil(t *testing.T) { + results, err := SearchTools([]mcp.Tool{{Name: "issue_list"}}, " ") + require.NoError(t, err) + require.Nil(t, results) +} + +func TestSearchTools_FindsByName(t *testing.T) { + tools := []mcp.Tool{ + {Name: "issue_list", Description: "List issues"}, + {Name: "repo_get", Description: "Get repository"}, + } + + results, err := SearchTools(tools, "issue", SearchOptions{MaxResults: 10}) + require.NoError(t, err) + require.NotEmpty(t, results) + require.Equal(t, "issue_list", results[0].Tool.Name) +} + +func TestSearchTools_FindsByParameterName_JSONSchema(t *testing.T) { + tools := []mcp.Tool{ + { + Name: "unrelated_tool", + Description: "does something else", + InputSchema: &jsonschema.Schema{Properties: map[string]*jsonschema.Schema{"owner": {}}}, + }, + } + + results, err := SearchTools(tools, "owner", SearchOptions{MaxResults: 10}) + require.NoError(t, err) + require.NotEmpty(t, results) + require.Equal(t, "unrelated_tool", results[0].Tool.Name) +} + +func TestSearchTools_FindsByParameterName_MapSchema(t *testing.T) { + tools := []mcp.Tool{ + { + Name: "unrelated_tool", + Description: "does something else", + InputSchema: map[string]any{"properties": map[string]any{"repo": map[string]any{}}}, + }, + } + + results, err := SearchTools(tools, "repo", SearchOptions{MaxResults: 10}) + require.NoError(t, err) + require.NotEmpty(t, results) + require.Equal(t, "unrelated_tool", results[0].Tool.Name) +} diff --git a/script/licenses b/script/licenses index 5aa8ec16b..23686315b 100755 --- a/script/licenses +++ b/script/licenses @@ -18,13 +18,9 @@ # depending on the license. set -e -# Pinned version for CI reproducibility, latest for local development +# Pinned version for reproducibility # See: https://github.com/cli/cli/pull/11161 -if [ "$CI" = "true" ]; then - go install github.com/google/go-licenses@5348b744d0983d85713295ea08a20cca1654a45e # v2.0.1 -else - go install github.com/google/go-licenses@latest -fi +go install github.com/google/go-licenses/v2@v2.0.1 # actions/setup-go does not setup the installed toolchain to be preferred over the system install, # which causes go-licenses to raise "Package ... does not have module info" errors in CI. diff --git a/script/list-scopes b/script/list-scopes new file mode 100755 index 000000000..2f7502823 --- /dev/null +++ b/script/list-scopes @@ -0,0 +1,24 @@ +#!/bin/bash +# +# List required OAuth scopes for enabled tools. +# +# Usage: +# script/list-scopes [--toolsets=...] [--output=text|json|summary] +# +# Examples: +# script/list-scopes +# script/list-scopes --toolsets=all --output=json +# script/list-scopes --toolsets=repos,issues --output=summary +# + +set -e + +cd "$(dirname "$0")/.." + +# Build the server if it doesn't exist or is outdated +if [ ! -f github-mcp-server ] || [ cmd/github-mcp-server/list_scopes.go -nt github-mcp-server ]; then + echo "Building github-mcp-server..." >&2 + go build -o github-mcp-server ./cmd/github-mcp-server +fi + +exec ./github-mcp-server list-scopes "$@" diff --git a/server.json b/server.json index 83b4e06be..15fdf47bd 100644 --- a/server.json +++ b/server.json @@ -8,6 +8,31 @@ "source": "github" }, "version": "${VERSION}", + "packages": [ + { + "registryType": "oci", + "identifier": "ghcr.io/github/github-mcp-server:${VERSION}", + "transport": { + "type": "stdio" + }, + "runtimeArguments": [ + { + "type": "named", + "name": "-e", + "description": "Set an environment variable in the runtime", + "value": "GITHUB_PERSONAL_ACCESS_TOKEN={token}", + "isRequired": true, + "variables": { + "token": { + "isRequired": true, + "isSecret": true, + "format": "string" + } + } + } + ] + } + ], "remotes": [ { "type": "streamable-http", @@ -15,8 +40,7 @@ "headers": [ { "name": "Authorization", - "description": "Authentication token (PAT or App token)", - "isRequired": true, + "description": "Authorization header with authentication token (PAT or App token)", "isSecret": true } ] diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 5cb31cac4..8217c7707 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -17,18 +17,16 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE)) - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE)) - - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.4.0/LICENSE)) - - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE)) + - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - [github.com/google/go-github/v79/github](https://pkg.go.dev/github.com/google/go-github/v79/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v79.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) - - [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE)) - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE)) - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md)) + - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE)) - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.2.0/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) @@ -49,7 +47,6 @@ The following packages are included for the amd64, arm64 architectures. - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) - [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.5.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index 8d0829a63..981e388e5 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -17,18 +17,16 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE)) - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE)) - - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.4.0/LICENSE)) - - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE)) + - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - [github.com/google/go-github/v79/github](https://pkg.go.dev/github.com/google/go-github/v79/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v79.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) - - [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE)) - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE)) - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md)) + - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE)) - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.2.0/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) @@ -49,7 +47,6 @@ The following packages are included for the 386, amd64, arm64 architectures. - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) - [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.5.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 79b925138..ae0e2389e 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -17,19 +17,17 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE)) - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE)) - - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.4.0/LICENSE)) - - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE)) + - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - [github.com/google/go-github/v79/github](https://pkg.go.dev/github.com/google/go-github/v79/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v79.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) - - [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE)) - [github.com/inconshreveable/mousetrap](https://pkg.go.dev/github.com/inconshreveable/mousetrap) ([Apache-2.0](https://github.com/inconshreveable/mousetrap/blob/v1.1.0/LICENSE)) - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE)) - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md)) + - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE)) - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.2.0/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) @@ -50,7 +48,6 @@ The following packages are included for the 386, amd64, arm64 architectures. - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) - [golang.org/x/sys/windows](https://pkg.go.dev/golang.org/x/sys/windows) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.5.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party/github.com/google/go-github/v71/github/LICENSE b/third-party/github.com/google/go-github/v71/github/LICENSE deleted file mode 100644 index 28b6486f0..000000000 --- a/third-party/github.com/google/go-github/v71/github/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2013 The go-github AUTHORS. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/gorilla/mux/LICENSE b/third-party/github.com/gorilla/mux/LICENSE deleted file mode 100644 index 6903df638..000000000 --- a/third-party/github.com/gorilla/mux/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2012-2018 The Gorilla Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/migueleliasweb/go-github-mock/src/mock/LICENSE b/third-party/github.com/lithammer/fuzzysearch/fuzzy/LICENSE similarity index 94% rename from third-party/github.com/migueleliasweb/go-github-mock/src/mock/LICENSE rename to third-party/github.com/lithammer/fuzzysearch/fuzzy/LICENSE index 86d42717d..dee3d1de2 100644 --- a/third-party/github.com/migueleliasweb/go-github-mock/src/mock/LICENSE +++ b/third-party/github.com/lithammer/fuzzysearch/fuzzy/LICENSE @@ -1,6 +1,6 @@ -MIT License +The MIT License (MIT) -Copyright (c) 2021 Miguel Elias dos Santos +Copyright (c) 2018 Peter Lithammer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/third-party/golang.org/x/time/rate/LICENSE b/third-party/golang.org/x/time/rate/LICENSE deleted file mode 100644 index 6a66aea5e..000000000 --- a/third-party/golang.org/x/time/rate/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2009 The Go Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.