diff --git a/.github/ISSUE_TEMPLATE/insiders-feedback.md b/.github/ISSUE_TEMPLATE/insiders-feedback.md new file mode 100644 index 0000000000..5b1f87f8ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/insiders-feedback.md @@ -0,0 +1,14 @@ +--- +name: Insiders Feedback +about: Give feedback related to a GitHub MCP Server Insiders feature +title: "Insiders Feedback: " +labels: '' +assignees: '' + +--- + +Version: Insiders + +Feature: + +Feedback: diff --git a/.github/actions/build-ui/action.yml b/.github/actions/build-ui/action.yml new file mode 100644 index 0000000000..229057d5cb --- /dev/null +++ b/.github/actions/build-ui/action.yml @@ -0,0 +1,38 @@ +name: Build UI +description: Restore cached UI HTML artifacts, or set up Node and run script/build-ui on cache miss. + +runs: + using: composite + steps: + - name: Cache UI artifacts + id: cache-ui + uses: actions/cache@v5 + with: + path: | + pkg/github/ui_dist/get-me.html + pkg/github/ui_dist/issue-write.html + pkg/github/ui_dist/pr-write.html + key: ui-dist-v1-${{ hashFiles('ui/package-lock.json', 'ui/package.json', 'ui/index.html', 'ui/tsconfig*.json', 'ui/vite.config.ts', 'ui/src/**', 'ui/scripts/**') }} + enableCrossOsArchive: true + + - name: Set up Node.js + if: steps.cache-ui.outputs.cache-hit != 'true' + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: npm + cache-dependency-path: ui/package-lock.json + + - name: Build UI + if: steps.cache-ui.outputs.cache-hit != 'true' + shell: bash + run: script/build-ui + + - name: Report UI cache status + shell: bash + run: | + if [ "${{ steps.cache-ui.outputs.cache-hit }}" = "true" ]; then + echo "UI artifacts restored from cache (skipped build)." + else + echo "UI artifacts rebuilt from source." + fi diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f1b4cf9cb6..975df2a633 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -94,7 +94,7 @@ go test ./pkg/github -run TestGetMe - **go.mod / go.sum:** Go module dependencies (Go 1.24.0+) - **.golangci.yml:** Linter configuration (v2 format, ~15 linters enabled) -- **Dockerfile:** Multi-stage build (golang:1.25.3-alpine → distroless) +- **Dockerfile:** Multi-stage build (golang:1.25.8-alpine → distroless) - **server.json:** MCP server metadata for registry - **.goreleaser.yaml:** Release automation config - **.gitignore:** Excludes bin/, dist/, vendor/, *.DS_Store, github-mcp-server binary @@ -243,7 +243,6 @@ All workflows run on push/PR unless noted. Located in `.github/workflows/`: - **GITHUB_HOST** - For GitHub Enterprise Server (prefix with `https://`) - **GITHUB_TOOLSETS** - Comma-separated toolset list (overrides --toolsets flag) - **GITHUB_READ_ONLY** - Set to "1" for read-only mode -- **GITHUB_DYNAMIC_TOOLSETS** - Set to "1" for dynamic toolset discovery - **UPDATE_TOOLSNAPS** - Set to "true" when running tests to update snapshots - **GITHUB_MCP_SERVER_E2E_TOKEN** - Token for e2e tests - **GITHUB_MCP_SERVER_E2E_DEBUG** - Set to "true" for in-process e2e debugging @@ -273,7 +272,7 @@ server.json - MCP server registry metadata `cmd/github-mcp-server/main.go` - Uses cobra for CLI, viper for config, supports: - `stdio` command (default) - MCP stdio transport - `generate-docs` command - Documentation generation -- Flags: --toolsets, --read-only, --dynamic-toolsets, --gh-host, --log-file +- Flags: --toolsets, --read-only, --gh-host, --log-file ## Important Reminders diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml index e58a45e71e..ecbe9f0dcb 100644 --- a/.github/workflows/code-scanning.yml +++ b/.github/workflows/code-scanning.yml @@ -78,9 +78,9 @@ jobs: go-version: ${{ fromJSON(steps.resolve-environment.outputs.environment).configuration.go.version }} cache: false - - name: Set up Node.js - if: matrix.language == 'go' || matrix.language == 'javascript' - uses: actions/setup-node@v4 + - name: Set up Node.js (for JavaScript CodeQL) + if: matrix.language == 'javascript' + uses: actions/setup-node@v6 with: node-version: "20" cache: "npm" @@ -88,7 +88,7 @@ jobs: - name: Build UI if: matrix.language == 'go' - run: script/build-ui + uses: ./.github/actions/build-ui - name: Autobuild uses: github/codeql-action/autobuild@v4 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 4ce7356f37..4f452aac41 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -46,7 +46,7 @@ jobs: # https://github.com/sigstore/cosign-installer - name: Install cosign if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 #v4.1.0 + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 #v4.1.2 with: cosign-release: "v2.2.4" @@ -54,13 +54,13 @@ jobs: # multi-platform images and export cache # https://github.com/docker/setup-buildx-action - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 # Login against a Docker registry except on PR # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -70,7 +70,7 @@ jobs: # https://github.com/docker/metadata-action - name: Extract Docker metadata id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -93,7 +93,7 @@ jobs: key: ${{ runner.os }}-go-build-cache-${{ hashFiles('**/go.sum') }} - name: Inject go-build-cache - uses: reproducible-containers/buildkit-cache-dance@1b8ab18fbda5ad3646e3fcc9ed9dd41ce2f297b4 # v3.3.2 + uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0 with: cache-map: | { @@ -106,7 +106,7 @@ jobs: # https://github.com/docker/build-push-action - name: Build and push Docker image id: build-and-push - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . push: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index de62d6282c..309eddb38e 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -16,15 +16,8 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ui/package-lock.json - - name: Build UI - run: script/build-ui + uses: ./.github/actions/build-ui - name: Set up Go uses: actions/setup-go@v6 diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index f874b2b59d..1fea50114a 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -25,16 +25,8 @@ jobs: - name: Check out code uses: actions/checkout@v6 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ui/package-lock.json - - name: Build UI - shell: bash - run: script/build-ui + uses: ./.github/actions/build-ui - name: Set up Go uses: actions/setup-go@v6 diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index f8eddc076c..1004fc2747 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -16,15 +16,8 @@ jobs: - name: Check out code uses: actions/checkout@v6 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ui/package-lock.json - - name: Build UI - run: script/build-ui + uses: ./.github/actions/build-ui - name: Set up Go uses: actions/setup-go@v6 @@ -35,7 +28,7 @@ jobs: run: go mod download - name: Run GoReleaser - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 with: distribution: goreleaser # GoReleaser version @@ -47,7 +40,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Generate signed build provenance attestations for workflow artifacts - uses: actions/attest-build-provenance@v3 + uses: actions/attest-build-provenance@v4 with: subject-path: | dist/*.tar.gz diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index 8726f82530..2f27353d83 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -32,15 +32,8 @@ jobs: GH_TOKEN: ${{ github.token }} run: gh pr checkout ${{ github.event.pull_request.number }} - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ui/package-lock.json - - name: Build UI - run: script/build-ui + uses: ./.github/actions/build-ui - name: Set up Go uses: actions/setup-go@v6 @@ -77,7 +70,7 @@ jobs: - name: Check if already commented if: steps.changes.outcome == 'failure' && steps.push.outcome == 'failure' id: check_comment - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const { data: comments } = await github.rest.issues.listComments({ @@ -95,7 +88,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@v8 + uses: actions/github-script@v9 with: script: | await github.rest.issues.createComment({ diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3676cb4103..5b912cea0f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,13 +14,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ui/package-lock.json - name: Build UI - run: script/build-ui + uses: ./.github/actions/build-ui - uses: actions/setup-go@v6 with: go-version: '1.25' diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml index 3c6c0149a8..62f08bacb0 100644 --- a/.github/workflows/mcp-diff.yml +++ b/.github/workflows/mcp-diff.yml @@ -19,52 +19,48 @@ jobs: with: fetch-depth: 0 - - name: Set up Node.js - uses: actions/setup-node@v4 + - name: Set up Go + uses: actions/setup-go@v6 with: - node-version: '20' + go-version-file: go.mod - name: Build UI - run: script/build-ui + uses: ./.github/actions/build-ui + + - name: Stash UI artifacts for baseline checkout + # mcp-server-diff checks the baseline ref out into a separate working + # directory and runs install_command there. Without these prebuilt + # artifacts, pkg/github/ui_dist/ would be empty on the baseline side + # and UIAssetsAvailable() would return false, producing a false-positive + # diff that "adds" _meta.ui to MCP Apps tools on every PR. + run: | + mkdir -p "${RUNNER_TEMP}/ui_dist" + cp pkg/github/ui_dist/*.html "${RUNNER_TEMP}/ui_dist/" + + - name: Generate diff configurations + id: configs + # The generator imports pkg/github so any new entry in + # AllowedFeatureFlags is automatically diffed without touching this + # workflow. See script/print-mcp-diff-configs/main.go. + run: | + { + echo 'configurations<> "$GITHUB_OUTPUT" - name: Run MCP Server Diff uses: SamMorrowDrums/mcp-server-diff@v2.3.5 with: - setup_go: "true" - install_command: go mod download + setup_go: "false" + install_command: | + go mod download + mkdir -p pkg/github/ui_dist + cp "${RUNNER_TEMP}"/ui_dist/*.html pkg/github/ui_dist/ 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": {}}}} - ] - } - ] + configurations: ${{ steps.configs.outputs.configurations }} - name: Add interpretation note if: always() @@ -78,3 +74,61 @@ jobs: echo "- New tools/toolsets added" >> $GITHUB_STEP_SUMMARY echo "- Tool descriptions updated" >> $GITHUB_STEP_SUMMARY echo "- Capability changes (intentional improvements)" >> $GITHUB_STEP_SUMMARY + + mcp-diff-http: + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Build UI + uses: ./.github/actions/build-ui + + - name: Stash UI artifacts for baseline checkout + # See the stdio job above for rationale: the action's baseline checkout + # has no UI artifacts unless we hand them over via RUNNER_TEMP. + run: | + mkdir -p "${RUNNER_TEMP}/ui_dist" + cp pkg/github/ui_dist/*.html "${RUNNER_TEMP}/ui_dist/" + + - name: Generate diff configurations + id: configs + # See script/print-mcp-diff-configs/main.go. The http-headers variant + # points every config at a shared HTTP server started by the action + # and carries per-config settings via X-MCP-* headers, mirroring how + # the remote server is invoked in production (server-side defaults + + # per-user header overrides). + run: | + { + echo 'configurations<> "$GITHUB_OUTPUT" + + - name: Run MCP Server Diff (streamable-http) + uses: SamMorrowDrums/mcp-server-diff@v2.3.5 + with: + setup_go: "false" + install_command: | + go mod download + mkdir -p pkg/github/ui_dist + cp "${RUNNER_TEMP}"/ui_dist/*.html pkg/github/ui_dist/ + http_start_command: go run ./cmd/github-mcp-server http --port 8082 + http_startup_wait_ms: "5000" + configurations: ${{ steps.configs.outputs.configurations }} + + - name: Add interpretation note + if: always() + run: | + echo "" >> $GITHUB_STEP_SUMMARY + echo "---" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "ℹ️ **Note:** This job exercises the streamable-http transport against a shared server, with per-config settings supplied via X-MCP-* request headers." >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 8d5d8b7ea2..dc0a5f3a31 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,9 @@ bin/ .DS_Store # binary -github-mcp-server -mcpcurl -e2e.test +/github-mcp-server +/mcpcurl +/e2e.test .history conformance-report/ diff --git a/Dockerfile b/Dockerfile index b13ae62d17..65d0f9e1ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine@sha256:09e2b3d9726018aecf269bd35325f46bf75046a643a66d28360ec71132750ec8 AS ui-build +FROM node:26-alpine@sha256:7c6af15abe4e3de859690e7db171d0d711bf37d27528eddfe625b2fe89e097f8 AS ui-build WORKDIR /app COPY ui/package*.json ./ui/ RUN cd ui && npm ci @@ -7,7 +7,7 @@ COPY ui/ ./ui/ RUN mkdir -p ./pkg/github/ui_dist && \ cd ui && npm run build -FROM golang:1.25.8-alpine@sha256:8e02eb337d9e0ea459e041f1ee5eece41cbb61f1d83e7d883a3e2fb4862063fa AS build +FROM golang:1.25.10-alpine@sha256:8d22e29d960bc50cd025d93d5b7c7d220b1ee9aa7a239b3c8f55a57e987e8d45 AS build ARG VERSION="dev" # Set the working directory @@ -30,7 +30,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ -o /bin/github-mcp-server ./cmd/github-mcp-server # Make a stage to run the app -FROM gcr.io/distroless/base-debian12@sha256:937c7eaaf6f3f2d38a1f8c4aeff326f0c56e4593ea152e9e8f74d976dde52f56 +FROM gcr.io/distroless/base-debian12@sha256:58695f439f772a00009c8f6be4c183f824c1f556d74b313c30900f167e4772f8 # Add required MCP server annotation LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server" diff --git a/README.md b/README.md index 419f892979..dff62321b8 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,9 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block - **[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 +- **[OpenCode](/docs/installation-guides/install-opencode.md)** - Installation guide for the OpenCode terminal agent - **[Windsurf](/docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE +- **[Zed](/docs/installation-guides/install-zed.md)** - Installation guide for Zed editor - **[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. @@ -212,7 +214,7 @@ To keep your GitHub PAT secure and reusable across different MCP hosts: ```bash # CLI usage - claude mcp update github -e GITHUB_PERSONAL_ACCESS_TOKEN=$GITHUB_PAT + claude mcp add github -e GITHUB_PERSONAL_ACCESS_TOKEN=$GITHUB_PAT -- docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server # In config files (where supported) "env": { @@ -356,7 +358,9 @@ For other MCP host applications, please refer to our installation guides: - **[Claude Code & Claude Desktop](docs/installation-guides/install-claude.md)** - Installation guide for Claude Code and Claude Desktop - **[Cursor](docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE - **[Google Gemini CLI](docs/installation-guides/install-gemini-cli.md)** - Installation guide for Google Gemini CLI +- **[OpenCode](docs/installation-guides/install-opencode.md)** - Installation guide for the OpenCode terminal agent - **[Windsurf](docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE +- **[Zed](docs/installation-guides/install-zed.md)** - Installation guide for Zed editor For a complete overview of all installation options, see our **[Installation Guides Index](docs/installation-guides)**. @@ -424,7 +428,7 @@ The environment variable `GITHUB_TOOLSETS` takes precedence over the command lin #### Specifying Individual Tools -You can also configure specific tools using the `--tools` flag. Tools can be used independently or combined with toolsets and dynamic toolsets discovery for fine-grained control. +You can also configure specific tools using the `--tools` flag. Tools can be used independently or combined with toolsets for fine-grained control. 1. **Using Command Line Argument**: @@ -446,20 +450,12 @@ You can also configure specific tools using the `--tools` flag. Tools can be use 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 +- Tools and toolsets can 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 -- When tools are renamed, old names are preserved as aliases for backward compatibility. See [Deprecated Tool Aliases](docs/deprecated-tool-aliases.md) for details. +- When tools are renamed, old names are preserved as aliases for backward compatibility. See [Tool Renaming](docs/tool-renaming.md) for details. ### Using Toolsets With Docker @@ -657,6 +653,8 @@ The following sets of tools are available: - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` - `owner`: The owner of the repository. (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `ref`: The Git reference for the results you want to list. (string, optional) - `repo`: The name of the repository. (string, required) - `severity`: Filter code scanning alerts by severity (string, optional) @@ -720,6 +718,8 @@ The following sets of tools are available: - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` - `owner`: The owner of the repository. (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: The name of the repository. (string, required) - `severity`: Filter dependabot alerts by severity (string, optional) - `state`: Filter dependabot alerts by state. Defaults to open (string, optional) @@ -730,6 +730,23 @@ The following sets of tools are available: comment-discussion Discussions +- **discussion_comment_write** - Manage discussion comments + - **Required OAuth Scopes**: `repo` + - `body`: Comment content (required for 'add', 'reply', and 'update' methods) (string, optional) + - `commentNodeID`: The Node ID of the discussion comment (required for 'reply', 'update', 'delete', 'mark_answer', and 'unmark_answer' methods). For 'reply', this is the top-level comment to reply to; GitHub Discussions only support one level of nesting. (string, optional) + - `discussionNumber`: Discussion number (required for 'add' and 'reply' methods) (number, optional) + - `method`: Write operation to perform on a discussion comment. + Options are: + - 'add' - adds a new top-level comment to a discussion. + - 'reply' - replies to a top-level discussion comment (GitHub Discussions only support one level of nesting). + - 'update' - updates an existing discussion comment. + - 'delete' - deletes a discussion comment. + - 'mark_answer' - marks a discussion comment as the answer (Q&A only). + - 'unmark_answer' - unmarks a discussion comment as the answer (Q&A only). + (string, required) + - `owner`: Repository owner (required for 'add' and 'reply' methods) (string, optional) + - `repo`: Repository name (required for 'add' and 'reply' methods) (string, optional) + - **get_discussion** - Get discussion - **Required OAuth Scopes**: `repo` - `discussionNumber`: Discussion Number (number, required) @@ -740,6 +757,7 @@ The following sets of tools are available: - **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) + - `includeReplies`: When true, each top-level comment will include its replies nested within it (up to 100 replies per comment, which is the GitHub API maximum). Defaults to false. (boolean, optional) - `owner`: Repository owner (string, required) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) @@ -815,7 +833,7 @@ The following sets of tools are available: - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) -- **get_label** - Get a specific label from a repository. +- **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) @@ -836,7 +854,7 @@ The following sets of tools are available: - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: The name of the repository (string, required) -- **issue_write** - Create or update issue. +- **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) @@ -905,13 +923,13 @@ The following sets of tools are available: tag Labels -- **get_label** - Get a specific label from a repository. +- **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. +- **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) @@ -1014,22 +1032,26 @@ The following sets of tools are available: - `project_number`: The project's number. Required for 'list_project_fields', 'list_project_items', and 'list_project_status_updates' 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 +- **projects_write** - Manage GitHub Projects - **Required OAuth Scopes**: `project` - `body`: The body of the status update (markdown). Used for 'create_project_status_update' method. (string, optional) + - `field_name`: The name of the iteration field (e.g. 'Sprint'). Required for 'create_iteration_field' method. (string, optional) - `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) + - `iteration_duration`: Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method. (number, optional) + - `iterations`: Custom iterations for 'create_iteration_field' method. Only set this when you need iterations with varying durations, breaks between them, or specific titles. Otherwise omit it: GitHub auto-creates three iterations of 'iteration_duration' days starting on 'start_date', which is the right choice for most cases. (object[], 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) + - `owner_type`: Owner type (user or org). Required for 'create_project' method. If not provided for other methods, will be automatically detected. (string, optional) + - `project_number`: The project's number. Required for all methods except 'create_project'. (number, optional) - `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) - - `start_date`: The start date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional) + - `start_date`: Start date in YYYY-MM-DD format. Used for 'create_project_status_update' and 'create_iteration_field' methods. (string, optional) - `status`: The status of the project. Used for 'create_project_status_update' method. (string, optional) - `target_date`: The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional) + - `title`: The project title. Required for 'create_project' method. (string, 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) @@ -1093,6 +1115,7 @@ The following sets of tools are available: - **pull_request_read** - Get details for a single pull request - **Required OAuth Scopes**: `repo` + - `after`: Cursor for pagination, used only by the get_review_comments method. Pass the endCursor from the previous page's PageInfo to fetch the next page. (string, optional) - `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. @@ -1100,7 +1123,7 @@ The following sets of tools are available: 3. get_status - Get combined commit status of a head commit in a pull request. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. 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. - 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. + 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned. 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. 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. (string, required) @@ -1110,7 +1133,7 @@ The following sets of tools are available: - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) -- **pull_request_review_write** - Write operations (create, submit, delete) on pull request reviews. +- **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) @@ -1140,7 +1163,7 @@ The following sets of tools are available: - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number to update (number, required) - `repo`: Repository name (string, required) - - `reviewers`: GitHub usernames to request reviews from (string[], optional) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) - `state`: New state (string, optional) - `title`: New title (string, optional) @@ -1198,7 +1221,7 @@ The following sets of tools are available: - **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) + - `detail`: Level of detail to include for changed files. "none" omits stats and files entirely. "stats" (default) includes per-file metadata: filename, status, and lines-of-code counts (additions, deletions, changes), with no patch content. "full_patch" additionally includes the unified diff content for each file and can be very large. (string, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -1256,6 +1279,14 @@ The following sets of tools are available: - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) +- **list_repository_collaborators** - List repository collaborators + - **Required OAuth Scopes**: `repo` + - `affiliation`: Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all' (string, optional) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (default 1, min 1) (number, optional) + - `perPage`: Results per page for pagination (default 30, min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - **list_tags** - List tags - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) @@ -1276,9 +1307,17 @@ The following sets of tools are available: - `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) - - `query`: 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. (string, required) + - `query`: Search query (GitHub code search REST). Implicit AND between terms; supports `OR`, `NOT`, and `"quoted phrase"` for exact match. Qualifiers: `repo:owner/repo`, `org:`, `user:`, `language:`, `path:dir` (prefix match), `filename:exact.ext`, `extension:`, `in:file`, `in:path`, `size:`, `is:archived`, `is:fork`. Max 256 chars. Examples: `WithContext language:go org:github`; `"package main" repo:o/r`; `func extension:go path:cmd repo:o/r`; `NOT TODO language:go repo:o/r`. (string, required) - `sort`: Sort field ('indexed' only) (string, optional) +- **search_commits** - Search commits + - **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) + - `query`: Commit search query (GitHub commit search REST). Searches commit messages on the default branch only. Scope the search with `repo:owner/repo`, `org:`, or `user:` (queries without a scope qualifier match across all of GitHub and are usually not what you want). Other qualifiers: `author:`, `committer:`, `author-name:`, `committer-name:`, `author-email:`, `committer-email:`, `author-date:`, `committer-date:` (supports `>`, `<`, `>=`, `<=`, and `YYYY-MM-DD..YYYY-MM-DD` ranges), `merge:true|false`, `hash:`, `tree:`, `parent:`, `is:public`. Examples: `repo:owner/repo fix panic`; `org:github author:defunkt committer-date:>=2024-01-01`; `"refactor cache" repo:o/r`; `hash:abc1234 repo:o/r`. (string, required) + - `sort`: Sort by author or committer date (defaults to best match) (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) @@ -1305,6 +1344,8 @@ The following sets of tools are available: - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` - `owner`: The owner of the repository. (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: The name of the repository. (string, required) - `resolution`: Filter by resolution (string, optional) - `secret_type`: 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. (string, optional) @@ -1413,6 +1454,11 @@ The following sets of tools are available: Copilot Spaces +- **Authentication note** + - Fine-grained PATs are not hidden by classic PAT scope filtering, so these tools may still appear even when the token cannot use them. + - For org-owned spaces, fine-grained PATs must be installed on the owning organization and include `organization_copilot_spaces: read`. + - If an org-owned space contains repository-backed resources, the token must also have access to every referenced repository or the space may be treated as not found. + - **get_copilot_space** - Get Copilot Space - `owner`: The owner of the space. (string, required) - `name`: The name of the space. (string, required) @@ -1430,29 +1476,6 @@ The following sets of tools are available: -## Dynamic Tool Discovery - -**Note**: This feature is currently in beta and is not available in the Remote GitHub MCP Server. Please test it out and let us know if you encounter any issues. - -Instead of starting with all tools enabled, you can turn on dynamic toolset discovery. Dynamic toolsets allow the MCP host to list and enable toolsets in response to a user prompt. This should help to avoid situations where the model gets confused by the sheer number of tools available. - -### Using Dynamic Tool Discovery - -When using the binary, you can pass the `--dynamic-toolsets` flag. - -```bash -./github-mcp-server --dynamic-toolsets -``` - -When using Docker, you can pass the toolsets as environment variables: - -```bash -docker run -i --rm \ - -e GITHUB_PERSONAL_ACCESS_TOKEN= \ - -e GITHUB_DYNAMIC_TOOLSETS=1 \ - ghcr.io/github/github-mcp-server -``` - ## Read-Only Mode To run the server in read-only mode, you can use the `--read-only` flag. This will only offer read-only tools, preventing any modifications to repositories, issues, pull requests, etc. diff --git a/cmd/github-mcp-server/feature_flag_docs.go b/cmd/github-mcp-server/feature_flag_docs.go new file mode 100644 index 0000000000..e52237b138 --- /dev/null +++ b/cmd/github-mcp-server/feature_flag_docs.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "fmt" + "os" + "reflect" + "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" +) + +// generateInsidersFeaturesDocs refreshes the auto-generated section of +// docs/insiders-features.md with the tools and schemas affected by each +// Insiders feature flag. +func generateInsidersFeaturesDocs(docsPath string) error { + body := generateFlaggedToolsDoc(github.InsidersFeatureFlags, "_No Insiders-only tool changes._") + return rewriteAutomatedSection(docsPath, "START AUTOMATED INSIDERS TOOLS", "END AUTOMATED INSIDERS TOOLS", body) +} + +// generateFeatureFlagsDocs refreshes the auto-generated section of +// docs/feature-flags.md with the tools and schemas affected by each +// user-controllable feature flag. +func generateFeatureFlagsDocs(docsPath string) error { + body := generateFlaggedToolsDoc(github.AllowedFeatureFlags, "_No user-controllable feature flags affect tool registration._") + return rewriteAutomatedSection(docsPath, "START AUTOMATED FEATURE FLAG TOOLS", "END AUTOMATED FEATURE FLAG TOOLS", body) +} + +// generateFlaggedToolsDoc renders, for each flag in the input set, the tools +// whose registration or definition differs from the default user experience. +// Each affected tool is printed with its full schema using the same writer +// used by the README so the output style stays consistent. +func generateFlaggedToolsDoc(flags []string, emptyMessage string) string { + t, _ := translations.TranslationHelper() + defaultTools := indexToolsByName(buildInventoryWithFlags(t, nil).ToolsForRegistration(context.Background())) + + var buf strings.Builder + hasAny := false + + for _, flag := range flags { + affected := flaggedToolDiff(t, flag, defaultTools) + if len(affected) == 0 { + continue + } + + if hasAny { + buf.WriteString("\n\n") + } + hasAny = true + + fmt.Fprintf(&buf, "### `%s`\n\n", flag) + for i, tool := range affected { + writeToolDoc(&buf, tool) + if i < len(affected)-1 { + buf.WriteString("\n\n") + } + } + } + + if !hasAny { + return emptyMessage + } + // Leading/trailing newlines around the body produce blank lines between + // our content and the surrounding marker comments, so the trailing comment + // doesn't get absorbed into the final list item by markdown renderers. + return "\n" + strings.TrimSuffix(buf.String(), "\n") + "\n" +} + +// flaggedToolDiff returns the tools whose definition (input schema or meta) +// differs from the default-flagged inventory when only the given flag is on, +// plus tools that exist only in the flag-on inventory. Results are sorted by +// tool name. +func flaggedToolDiff(t translations.TranslationHelperFunc, flag string, defaultTools map[string]inventory.ServerTool) []inventory.ServerTool { + flagTools := buildInventoryWithFlags(t, map[string]bool{flag: true}).ToolsForRegistration(context.Background()) + + out := make([]inventory.ServerTool, 0) + seen := make(map[string]struct{}, len(flagTools)) + + for _, tool := range flagTools { + if _, ok := seen[tool.Tool.Name]; ok { + continue + } + seen[tool.Tool.Name] = struct{}{} + + baseline, hadBaseline := defaultTools[tool.Tool.Name] + if hadBaseline && reflect.DeepEqual(tool.Tool.InputSchema, baseline.Tool.InputSchema) && reflect.DeepEqual(tool.Tool.Meta, baseline.Tool.Meta) { + continue + } + out = append(out, tool) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Tool.Name < out[j].Tool.Name }) + return out +} + +// buildInventoryWithFlags constructs an inventory whose feature checker treats +// the given flags as enabled and every other flag as disabled. Passing nil +// produces the default-flagged inventory. +func buildInventoryWithFlags(t translations.TranslationHelperFunc, enabled map[string]bool) *inventory.Inventory { + checker := func(_ context.Context, flag string) (bool, error) { + return enabled[flag], nil + } + inv, _ := github.NewInventory(t). + WithToolsets([]string{"all"}). + WithFeatureChecker(checker). + Build() + return inv +} + +// indexToolsByName returns a map keyed by tool name. When duplicates exist +// (e.g. flag-gated dual registrations), the first occurrence wins, mirroring +// AvailableTools' deterministic sort order. +func indexToolsByName(tools []inventory.ServerTool) map[string]inventory.ServerTool { + out := make(map[string]inventory.ServerTool, len(tools)) + for _, tool := range tools { + if _, ok := out[tool.Tool.Name]; ok { + continue + } + out[tool.Tool.Name] = tool + } + return out +} + +// rewriteAutomatedSection reads a markdown file, replaces the content between +// the named markers with body, and writes it back. +func rewriteAutomatedSection(path, startMarker, endMarker, body string) error { + content, err := os.ReadFile(path) //#nosec G304 + if err != nil { + return fmt.Errorf("failed to read docs file: %w", err) + } + updated, err := replaceSection(string(content), startMarker, endMarker, body) + if err != nil { + return err + } + return os.WriteFile(path, []byte(updated), 0600) //#nosec G306 +} diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 7d7b1f6ab3..78ed8361a8 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -29,6 +29,12 @@ func init() { rootCmd.AddCommand(generateDocsCmd) } +// noFeatureFlagsChecker reports every feature flag as disabled. It models the +// default user experience used by the generated documentation. +func noFeatureFlagsChecker(_ context.Context, _ string) (bool, error) { + return false, nil +} + func generateAllDocs() error { for _, doc := range []struct { path string @@ -37,6 +43,8 @@ func generateAllDocs() error { // File to edit, function to generate its docs {"README.md", generateReadmeDocs}, {"docs/remote-server.md", generateRemoteServerDocs}, + {"docs/insiders-features.md", generateInsidersFeaturesDocs}, + {"docs/feature-flags.md", generateFeatureFlagsDocs}, {"docs/tool-renaming.md", generateDeprecatedAliasesDocs}, } { if err := doc.fn(doc.path); err != nil { @@ -51,9 +59,16 @@ func generateReadmeDocs(readmePath string) error { // Create translation helper t, _ := translations.TranslationHelper() - // (not available to regular users) while including tools with FeatureFlagDisable. + // The README documents the default user experience: tools that are + // enabled with no special flags set. Installing a checker that reports + // every flag as disabled excludes tools gated by FeatureFlagEnable and + // keeps the legacy variants of tools gated by FeatureFlagDisable, so + // flag-gated duplicates don't appear twice. // Build() can only fail if WithTools specifies invalid tools - not used here - r, _ := github.NewInventory(t).WithToolsets([]string{"all"}).Build() + r, _ := github.NewInventory(t). + WithToolsets([]string{"all"}). + WithFeatureChecker(noFeatureFlagsChecker). + Build() // Generate toolsets documentation toolsetsDoc := generateToolsetsDoc(r) @@ -145,8 +160,8 @@ func generateToolsetsDoc(i *inventory.Inventory) string { fmt.Fprintf(&buf, "| %s | `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |\n", contextIcon) // AvailableToolsets() returns toolsets that have tools, sorted by ID - // Exclude context (custom description above) and dynamic (internal only) - for _, ts := range i.AvailableToolsets("context", "dynamic") { + // Exclude context (custom description above) + for _, ts := range i.AvailableToolsets("context") { icon := octiconImg(ts.Icon) fmt.Fprintf(&buf, "| %s | `%s` | %s |\n", icon, ts.ID, ts.Description) } @@ -155,7 +170,7 @@ func generateToolsetsDoc(i *inventory.Inventory) string { } func generateToolsDoc(r *inventory.Inventory) string { - tools := r.AvailableTools(context.Background()) + tools := r.ToolsForRegistration(context.Background()) if len(tools) == 0 { return "" } @@ -214,6 +229,15 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { } } + // MCP App UI metadata (only rendered when the remote_mcp_ui_apps flag + // applied to the inventory; for the no-flags README this section is + // stripped by inventory.ToolsForRegistration before rendering). + if ui, ok := tool.Tool.Meta["ui"].(map[string]any); ok { + if uri, ok := ui["resourceUri"].(string); ok && uri != "" { + fmt.Fprintf(buf, " - **MCP App UI**: `%s`\n", uri) + } + } + // Parameters if tool.Tool.InputSchema == nil { buf.WriteString(" - No parameters required") @@ -341,13 +365,15 @@ func generateRemoteToolsetsDoc() string { buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n") buf.WriteString("| ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- |\n") - // 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) + // Add "default" and "all" meta toolsets first (special cases). The base + // URL serves the default toolset; /x/all enables every toolset at once. + metaIcon := octiconImg("apps", "../") + fmt.Fprintf(&buf, "| %s
`default` | Default toolset | 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", metaIcon) + fmt.Fprintf(&buf, "| %s
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/x/all | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Fx%%2Fall%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/x/all/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Fx%%2Fall%%2Freadonly%%22%%7D) |\n", metaIcon) // AvailableToolsets() returns toolsets that have tools, sorted by ID - // Exclude context (handled separately) and dynamic (internal only) - for _, ts := range r.AvailableToolsets("context", "dynamic") { + // Exclude context (handled separately) + for _, ts := range r.AvailableToolsets("context") { idStr := string(ts.ID) apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 05c2c6e0be..558fdb9980 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -85,7 +85,6 @@ var ( EnabledToolsets: enabledToolsets, EnabledTools: enabledTools, EnabledFeatures: enabledFeatures, - DynamicToolsets: viper.GetBool("dynamic_toolsets"), ReadOnly: viper.GetBool("read-only"), ExportTranslations: viper.GetBool("export-translations"), EnableCommandLogging: viper.GetBool("enable-command-logging"), @@ -105,6 +104,35 @@ var ( Short: "Start HTTP server", Long: `Start an HTTP server that listens for MCP requests over HTTP.`, RunE: func(_ *cobra.Command, _ []string) error { + // Parse toolsets (same approach as stdio — see comment there) + var enabledToolsets []string + if viper.IsSet("toolsets") { + if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { + return fmt.Errorf("failed to unmarshal toolsets: %w", err) + } + } + + var enabledTools []string + if viper.IsSet("tools") { + if err := viper.UnmarshalKey("tools", &enabledTools); err != nil { + return fmt.Errorf("failed to unmarshal tools: %w", err) + } + } + + var excludeTools []string + if viper.IsSet("exclude_tools") { + if err := viper.UnmarshalKey("exclude_tools", &excludeTools); err != nil { + return fmt.Errorf("failed to unmarshal exclude-tools: %w", err) + } + } + + var enabledFeatures []string + if viper.IsSet("features") { + if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil { + return fmt.Errorf("failed to unmarshal features: %w", err) + } + } + ttl := viper.GetDuration("repo-access-cache-ttl") httpConfig := ghhttp.ServerConfig{ Version: version, @@ -119,6 +147,13 @@ var ( LockdownMode: viper.GetBool("lockdown-mode"), RepoAccessCacheTTL: &ttl, ScopeChallenge: viper.GetBool("scope-challenge"), + ReadOnly: viper.GetBool("read-only"), + EnabledToolsets: enabledToolsets, + EnabledTools: enabledTools, + ExcludeTools: excludeTools, + EnabledFeatures: enabledFeatures, + InsidersMode: viper.GetBool("insiders"), + TrustProxyHeaders: viper.GetBool("trust-proxy-headers"), } return ghhttp.RunHTTPServer(httpConfig) @@ -137,7 +172,6 @@ func init() { rootCmd.PersistentFlags().StringSlice("tools", nil, "Comma-separated list of specific tools to enable") rootCmd.PersistentFlags().StringSlice("exclude-tools", nil, "Comma-separated list of tool names to disable regardless of other settings") rootCmd.PersistentFlags().StringSlice("features", nil, "Comma-separated list of feature flags to enable") - rootCmd.PersistentFlags().Bool("dynamic-toolsets", false, "Enable dynamic toolsets") rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations") rootCmd.PersistentFlags().String("log-file", "", "Path to log file") rootCmd.PersistentFlags().Bool("enable-command-logging", false, "When enabled, the server will log all command requests and responses to the log file") @@ -153,13 +187,13 @@ func init() { httpCmd.Flags().String("base-url", "", "Base URL where this server is publicly accessible (for OAuth resource metadata)") httpCmd.Flags().String("base-path", "", "Externally visible base path for the HTTP server (for OAuth resource metadata)") httpCmd.Flags().Bool("scope-challenge", false, "Enable OAuth scope challenge responses") + httpCmd.Flags().Bool("trust-proxy-headers", false, "Honor X-Forwarded-Host and X-Forwarded-Proto when constructing OAuth resource metadata URLs. Only enable when the server is deployed behind a trusted proxy that sets these headers. Ignored when --base-url is set.") // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) _ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools")) _ = viper.BindPFlag("exclude_tools", rootCmd.PersistentFlags().Lookup("exclude-tools")) _ = viper.BindPFlag("features", rootCmd.PersistentFlags().Lookup("features")) - _ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets")) _ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only")) _ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file")) _ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging")) @@ -173,6 +207,7 @@ func init() { _ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url")) _ = viper.BindPFlag("base-path", httpCmd.Flags().Lookup("base-path")) _ = viper.BindPFlag("scope-challenge", httpCmd.Flags().Lookup("scope-challenge")) + _ = viper.BindPFlag("trust-proxy-headers", httpCmd.Flags().Lookup("trust-proxy-headers")) // Add subcommands rootCmd.AddCommand(stdioCmd) rootCmd.AddCommand(httpCmd) diff --git a/cmd/mcpcurl/main.go b/cmd/mcpcurl/main.go index f35e6926c3..f40e842530 100644 --- a/cmd/mcpcurl/main.go +++ b/cmd/mcpcurl/main.go @@ -1,7 +1,7 @@ package main import ( - "bytes" + "bufio" "crypto/rand" "encoding/json" "fmt" @@ -376,8 +376,8 @@ func buildJSONRPCRequest(method, toolName string, arguments map[string]any) (str return string(jsonData), nil } -// executeServerCommand runs the specified command, sends the JSON request to stdin, -// and returns the response from stdout +// executeServerCommand runs the specified command, performs the MCP initialization +// handshake, sends the JSON request to stdin, and returns the response from stdout. func executeServerCommand(cmdStr, jsonRequest string) (string, error) { // Split the command string into command and arguments cmdParts := strings.Fields(cmdStr) @@ -393,9 +393,14 @@ func executeServerCommand(cmdStr, jsonRequest string) (string, error) { return "", fmt.Errorf("failed to create stdin pipe: %w", err) } - // Setup stdout and stderr pipes - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout + // Setup stdout pipe for line-by-line reading + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return "", fmt.Errorf("failed to create stdout pipe: %w", err) + } + + // Stderr still uses a buffer + var stderr strings.Builder cmd.Stderr = &stderr // Start the command @@ -403,18 +408,104 @@ func executeServerCommand(cmdStr, jsonRequest string) (string, error) { return "", fmt.Errorf("failed to start command: %w", err) } - // Write the JSON request to stdin + // Ensure the child process is cleaned up on every return path. + // stdin must be closed before Wait so the server sees EOF and exits; + // its non-zero exit status on EOF is expected, so we ignore the error. + defer func() { + _ = stdin.Close() + _ = cmd.Wait() + }() + + // Use a scanner with a large buffer for reading JSON-RPC responses + scanner := bufio.NewScanner(stdoutPipe) + scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) // 1MB max line size + + // Step 1: Send MCP initialize request + initReq, err := buildInitializeRequest() + if err != nil { + return "", fmt.Errorf("failed to build initialize request: %w", err) + } + if _, err := io.WriteString(stdin, initReq+"\n"); err != nil { + return "", fmt.Errorf("failed to write initialize request: %w", err) + } + + // Step 2: Read initialize response (skip any server notifications) + if _, err := readJSONRPCResponse(scanner); err != nil { + return "", fmt.Errorf("failed to read initialize response: %w, stderr: %s", err, stderr.String()) + } + + // Step 3: Send initialized notification + if _, err := io.WriteString(stdin, buildInitializedNotification()+"\n"); err != nil { + return "", fmt.Errorf("failed to write initialized notification: %w", err) + } + + // Step 4: Send the actual request if _, err := io.WriteString(stdin, jsonRequest+"\n"); err != nil { - return "", fmt.Errorf("failed to write to stdin: %w", err) + return "", fmt.Errorf("failed to write request: %w", err) } - _ = stdin.Close() - // Wait for the command to complete - if err := cmd.Wait(); err != nil { - return "", fmt.Errorf("command failed: %w, stderr: %s", err, stderr.String()) + // Step 5: Read the actual response (skip any server notifications) + response, err := readJSONRPCResponse(scanner) + if err != nil { + return "", fmt.Errorf("failed to read response: %w, stderr: %s", err, stderr.String()) } - return stdout.String(), nil + return response, nil +} + +// buildInitializeRequest creates the MCP initialize handshake request. +func buildInitializeRequest() (string, error) { + id, err := rand.Int(rand.Reader, big.NewInt(10000)) + if err != nil { + return "", fmt.Errorf("failed to generate random ID: %w", err) + } + msg := map[string]any{ + "jsonrpc": "2.0", + "id": int(id.Int64()), + "method": "initialize", + "params": map[string]any{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{ + "name": "mcpcurl", + "version": "0.1.0", + }, + }, + } + data, err := json.Marshal(msg) + if err != nil { + return "", fmt.Errorf("failed to marshal initialize request: %w", err) + } + return string(data), nil +} + +// buildInitializedNotification creates the MCP initialized notification. +func buildInitializedNotification() string { + return `{"jsonrpc":"2.0","method":"notifications/initialized"}` +} + +// readJSONRPCResponse reads lines from the scanner, skipping server-initiated +// notifications (messages without an "id" field), and returns the first response. +func readJSONRPCResponse(scanner *bufio.Scanner) (string, error) { + for scanner.Scan() { + line := scanner.Text() + // JSON-RPC responses have an "id" field; notifications do not. + var msg map[string]json.RawMessage + if err := json.Unmarshal([]byte(line), &msg); err != nil { + return "", fmt.Errorf("failed to parse JSON-RPC message: %w", err) + } + if _, hasID := msg["id"]; hasID { + if errField, hasErr := msg["error"]; hasErr { + return "", fmt.Errorf("server returned error: %s", string(errField)) + } + return line, nil + } + // No "id" — this is a notification, skip it + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", fmt.Errorf("unexpected end of output") } func printResponse(response string, prettyPrint bool) error { diff --git a/cmd/mcpcurl/main_test.go b/cmd/mcpcurl/main_test.go new file mode 100644 index 0000000000..3d0b00d2a5 --- /dev/null +++ b/cmd/mcpcurl/main_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "bufio" + "encoding/json" + "strings" + "testing" +) + +func TestReadJSONRPCResponse_DirectResponse(t *testing.T) { + t.Parallel() + input := `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + "\n" + scanner := bufio.NewScanner(strings.NewReader(input)) + + got, err := readJSONRPCResponse(scanner) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` { + t.Fatalf("unexpected response: %s", got) + } +} + +func TestReadJSONRPCResponse_SkipsNotifications(t *testing.T) { + t.Parallel() + input := strings.Join([]string{ + `{"jsonrpc":"2.0","method":"notifications/resources/list_changed","params":{}}`, + `{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}`, + `{"jsonrpc":"2.0","id":42,"result":{"content":[{"type":"text","text":"hello"}]}}`, + }, "\n") + "\n" + scanner := bufio.NewScanner(strings.NewReader(input)) + + got, err := readJSONRPCResponse(scanner) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var msg map[string]json.RawMessage + if err := json.Unmarshal([]byte(got), &msg); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + // Verify we got the response with id:42, not a notification + var id int + if err := json.Unmarshal(msg["id"], &id); err != nil { + t.Fatalf("failed to parse id: %v", err) + } + if id != 42 { + t.Fatalf("expected id 42, got %d", id) + } +} + +func TestReadJSONRPCResponse_NoResponse(t *testing.T) { + t.Parallel() + // Only notifications, no response + input := `{"jsonrpc":"2.0","method":"notifications/resources/list_changed","params":{}}` + "\n" + scanner := bufio.NewScanner(strings.NewReader(input)) + + _, err := readJSONRPCResponse(scanner) + if err == nil { + t.Fatal("expected error for missing response, got nil") + } + if !strings.Contains(err.Error(), "unexpected end of output") { + t.Fatalf("expected 'unexpected end of output' error, got: %v", err) + } +} + +func TestReadJSONRPCResponse_EmptyInput(t *testing.T) { + t.Parallel() + scanner := bufio.NewScanner(strings.NewReader("")) + + _, err := readJSONRPCResponse(scanner) + if err == nil { + t.Fatal("expected error for empty input, got nil") + } +} + +func TestReadJSONRPCResponse_InvalidJSON(t *testing.T) { + t.Parallel() + input := "not valid json\n" + scanner := bufio.NewScanner(strings.NewReader(input)) + + _, err := readJSONRPCResponse(scanner) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + if !strings.Contains(err.Error(), "failed to parse JSON-RPC message") { + t.Fatalf("expected parse error, got: %v", err) + } +} + +func TestReadJSONRPCResponse_ServerError(t *testing.T) { + t.Parallel() + input := `{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}` + "\n" + scanner := bufio.NewScanner(strings.NewReader(input)) + + _, err := readJSONRPCResponse(scanner) + if err == nil { + t.Fatal("expected error for server error response, got nil") + } + if !strings.Contains(err.Error(), "server returned error") { + t.Fatalf("expected 'server returned error', got: %v", err) + } + if !strings.Contains(err.Error(), "method not found") { + t.Fatalf("expected error to contain server message, got: %v", err) + } +} + +func TestBuildInitializeRequest(t *testing.T) { + t.Parallel() + got, err := buildInitializeRequest() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var msg map[string]json.RawMessage + if err := json.Unmarshal([]byte(got), &msg); err != nil { + t.Fatalf("result is not valid JSON: %v", err) + } + + // Verify required fields + for _, field := range []string{"jsonrpc", "id", "method", "params"} { + if _, ok := msg[field]; !ok { + t.Errorf("missing required field %q", field) + } + } + + // Verify method + var method string + if err := json.Unmarshal(msg["method"], &method); err != nil { + t.Fatalf("failed to parse method: %v", err) + } + if method != "initialize" { + t.Errorf("expected method 'initialize', got %q", method) + } + + // Verify params contain protocolVersion and clientInfo + var params map[string]json.RawMessage + if err := json.Unmarshal(msg["params"], ¶ms); err != nil { + t.Fatalf("failed to parse params: %v", err) + } + for _, field := range []string{"protocolVersion", "capabilities", "clientInfo"} { + if _, ok := params[field]; !ok { + t.Errorf("missing params field %q", field) + } + } + + var version string + if err := json.Unmarshal(params["protocolVersion"], &version); err != nil { + t.Fatalf("failed to parse protocolVersion: %v", err) + } + if version != "2024-11-05" { + t.Errorf("expected protocolVersion '2024-11-05', got %q", version) + } +} + +func TestBuildInitializedNotification(t *testing.T) { + t.Parallel() + got := buildInitializedNotification() + + var msg map[string]json.RawMessage + if err := json.Unmarshal([]byte(got), &msg); err != nil { + t.Fatalf("result is not valid JSON: %v", err) + } + + // Must have jsonrpc and method + var method string + if err := json.Unmarshal(msg["method"], &method); err != nil { + t.Fatalf("failed to parse method: %v", err) + } + if method != "notifications/initialized" { + t.Errorf("expected method 'notifications/initialized', got %q", method) + } + + // Must NOT have an id (it's a notification) + if _, hasID := msg["id"]; hasID { + t.Error("notification should not have an 'id' field") + } +} diff --git a/docs/feature-flags.md b/docs/feature-flags.md new file mode 100644 index 0000000000..63fb28dc44 --- /dev/null +++ b/docs/feature-flags.md @@ -0,0 +1,290 @@ +# Feature Flags + +Feature flags let you opt into experimental tool behavior on top of the default +GitHub MCP Server surface. Insiders Mode turns on a curated subset of these +flags automatically — see [Insiders Features](./insiders-features.md) for that +specific set. + +For background on how flags resolve at request time, see the [resolution +section in the Insiders docs](./insiders-features.md#how-feature-flags-are-resolved). + +## Enabling a flag + +| Method | Remote Server | Local Server | +|--------|---------------|--------------| +| Header | `X-MCP-Features: ,` | N/A | +| CLI flag | N/A | `--features=,` | +| Environment variable | N/A | `GITHUB_FEATURES=,` | + +Only flags listed in +[`AllowedFeatureFlags`](../pkg/github/feature_flags.go) can be enabled by +end users. Insiders-only flags are not user-toggleable. + +--- + +## Tools affected by each flag + +The list below is regenerated from the Go source. For each user-controllable +feature flag, it lists every tool whose **inventory or input schema** differs +from the default — either because the flag introduces a new tool, or because +it selects a flag-aware variant of an existing tool. Flags that only affect +runtime behavior (such as output formatting) won't appear here. + + + +### `remote_mcp_ui_apps` + +- **create_pull_request** - Open new pull request + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/pr-write` + - `base`: Branch to merge into (string, required) + - `body`: PR description (string, optional) + - `draft`: Create as draft PR (boolean, optional) + - `head`: Branch containing changes (string, required) + - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `title`: PR title (string, required) + +- **get_me** - Get my user profile + - **MCP App UI**: `ui://github-mcp-server/get-me` + - No parameters required + +- **issue_write** - Create or update issue + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/issue-write` + - `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) + - `issue_number`: Issue number to update (number, optional) + - `labels`: Labels to apply to this issue (string[], optional) + - `method`: Write operation to perform on a single issue. + Options are: + - 'create' - creates a new issue. + - 'update' - updates an existing issue. + (string, required) + - `milestone`: Milestone number (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `state`: New state (string, optional) + - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) + - `title`: Issue title (string, optional) + - `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) + +### `remote_mcp_issue_fields` + +- **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) + - `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'. (object[], optional) + - `issue_number`: Issue number to update (number, optional) + - `labels`: Labels to apply to this issue (string[], optional) + - `method`: Write operation to perform on a single issue. + Options are: + - 'create' - creates a new issue. + - 'update' - updates an existing issue. + (string, required) + - `milestone`: Milestone number (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `state`: New state (string, optional) + - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) + - `title`: Issue title (string, optional) + - `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_fields** - List issue fields + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required) + - `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional) + +- **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) + - `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional) + - `labels`: Filter by labels (string[], optional) + - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional) + - `owner`: Repository owner (string, required) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - `since`: Filter by date (ISO 8601 timestamp) (string, optional) + - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) + +### `issues_granular` + +- **add_sub_issue** - Add Sub-Issue + - **Required OAuth Scopes**: `repo` + - `issue_number`: The parent issue number (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `replace_parent`: If true, reparent the sub-issue if it already has a parent (boolean, optional) + - `repo`: Repository name (string, required) + - `sub_issue_id`: The ID of the sub-issue to add. ID is not the same as issue number (number, required) + +- **create_issue** - Create Issue + - **Required OAuth Scopes**: `repo` + - `body`: Issue body content (optional) (string, optional) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `title`: Issue title (string, required) + +- **remove_sub_issue** - Remove Sub-Issue + - **Required OAuth Scopes**: `repo` + - `issue_number`: The parent issue number (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `sub_issue_id`: The ID of the sub-issue to remove. ID is not the same as issue number (number, required) + +- **reprioritize_sub_issue** - Reprioritize Sub-Issue + - **Required OAuth Scopes**: `repo` + - `after_id`: The ID of the sub-issue to place this after (either after_id OR before_id should be specified) (number, optional) + - `before_id`: The ID of the sub-issue to place this before (either after_id OR before_id should be specified) (number, optional) + - `issue_number`: The parent issue number (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `sub_issue_id`: The ID of the sub-issue to reorder. ID is not the same as issue number (number, required) + +- **set_issue_fields** - Set Issue Fields + - **Required OAuth Scopes**: `repo` + - `fields`: Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, or 'single_select_option_id' (the GraphQL node ID of the option) for single select fields. Set 'delete' to true to remove a field value. (object[], required) + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_assignees** - Update Issue Assignees + - **Required OAuth Scopes**: `repo` + - `assignees`: GitHub usernames to assign to this issue (string[], required) + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_body** - Update Issue Body + - **Required OAuth Scopes**: `repo` + - `body`: The new body content for the issue (string, required) + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_labels** - Update Issue Labels + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `labels`: Labels to apply to this issue. ([], required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_milestone** - Update Issue Milestone + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `milestone`: The milestone number to set on the issue (integer, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_state** - Update Issue State + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `state`: The new state for the issue (string, required) + - `state_reason`: The reason for the state change (only for closed state) (string, optional) + +- **update_issue_title** - Update Issue Title + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `title`: The new title for the issue (string, required) + +- **update_issue_type** - Update Issue Type + - **Required OAuth Scopes**: `repo` + - `confidence`: How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal. (string, optional) + - `is_suggestion`: If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. Whether the type is applied or recorded as a proposal is determined by the API. (boolean, optional) + - `issue_number`: The issue number to update (number, required) + - `issue_type`: The issue type to set (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `rationale`: One concise sentence explaining what specifically about the issue led you to choose this type. State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature). (string, optional) + - `repo`: Repository name (string, required) + +### `pull_requests_granular` + +- **add_pull_request_review_comment** - Add Pull Request Review Comment + - **Required OAuth Scopes**: `repo` + - `body`: The comment body (string, required) + - `line`: The line number in the diff to comment on (optional) (number, optional) + - `owner`: Repository owner (username or organization) (string, required) + - `path`: The relative path of the file to comment on (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `side`: The side of the diff to comment on (optional) (string, optional) + - `startLine`: The start line of a multi-line comment (optional) (number, optional) + - `startSide`: The start side of a multi-line comment (optional) (string, optional) + - `subjectType`: The subject type of the comment (string, required) + +- **create_pull_request_review** - Create Pull Request Review + - **Required OAuth Scopes**: `repo` + - `body`: The review body text (optional) (string, optional) + - `commitID`: The SHA of the commit to review (optional, defaults to latest) (string, optional) + - `event`: The review action to perform. If omitted, creates a pending review. (string, optional) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + +- **delete_pending_pull_request_review** - Delete Pending Pull Request Review + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + +- **request_pull_request_reviewers** - Request Pull Request Reviewers + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], required) + +- **resolve_review_thread** - Resolve Review Thread + - **Required OAuth Scopes**: `repo` + - `threadID`: The node ID of the review thread to resolve (e.g., PRRT_kwDOxxx) (string, required) + +- **submit_pending_pull_request_review** - Submit Pending Pull Request Review + - **Required OAuth Scopes**: `repo` + - `body`: The review body text (optional) (string, optional) + - `event`: The review action to perform (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + +- **unresolve_review_thread** - Unresolve Review Thread + - **Required OAuth Scopes**: `repo` + - `threadID`: The node ID of the review thread to unresolve (e.g., PRRT_kwDOxxx) (string, required) + +- **update_pull_request_body** - Update Pull Request Body + - **Required OAuth Scopes**: `repo` + - `body`: The new body content for the pull request (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + +- **update_pull_request_draft_state** - Update Pull Request Draft State + - **Required OAuth Scopes**: `repo` + - `draft`: Set to true to convert to draft, false to mark as ready for review (boolean, required) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + +- **update_pull_request_state** - Update Pull Request State + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `state`: The new state for the pull request (string, required) + +- **update_pull_request_title** - Update Pull Request Title + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `title`: The new title for the pull request (string, required) + + diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 911257ae4f..881030f020 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -20,6 +20,97 @@ For configuration examples, see the [Server Configuration Guide](./server-config --- +## Tools added or changed by Insiders Mode + +The list below is generated from the Go source. It covers tool **inventory and schema deltas** introduced by each Insiders feature flag — newly registered tools, or existing tools whose input schema or MCP metadata changes when the flag is on. Flags that only affect runtime behavior (e.g. output formatting or extra field lookups behind an existing schema) won't appear here; those are documented in the prose sections of this file. + + + +### `remote_mcp_ui_apps` + +- **create_pull_request** - Open new pull request + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/pr-write` + - `base`: Branch to merge into (string, required) + - `body`: PR description (string, optional) + - `draft`: Create as draft PR (boolean, optional) + - `head`: Branch containing changes (string, required) + - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `title`: PR title (string, required) + +- **get_me** - Get my user profile + - **MCP App UI**: `ui://github-mcp-server/get-me` + - No parameters required + +- **issue_write** - Create or update issue + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/issue-write` + - `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) + - `issue_number`: Issue number to update (number, optional) + - `labels`: Labels to apply to this issue (string[], optional) + - `method`: Write operation to perform on a single issue. + Options are: + - 'create' - creates a new issue. + - 'update' - updates an existing issue. + (string, required) + - `milestone`: Milestone number (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `state`: New state (string, optional) + - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) + - `title`: Issue title (string, optional) + - `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) + +### `remote_mcp_issue_fields` + +- **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) + - `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'. (object[], optional) + - `issue_number`: Issue number to update (number, optional) + - `labels`: Labels to apply to this issue (string[], optional) + - `method`: Write operation to perform on a single issue. + Options are: + - 'create' - creates a new issue. + - 'update' - updates an existing issue. + (string, required) + - `milestone`: Milestone number (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `state`: New state (string, optional) + - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) + - `title`: Issue title (string, optional) + - `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_fields** - List issue fields + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required) + - `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional) + +- **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) + - `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional) + - `labels`: Filter by labels (string[], optional) + - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional) + - `owner`: Repository owner (string, required) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - `since`: Filter by date (ISO 8601 timestamp) (string, optional) + - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) + + + +--- + ## MCP Apps [MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) is an extension to the Model Context Protocol that enables servers to deliver interactive user interfaces to end users. Instead of returning plain text that the LLM must interpret and relay, tools can render forms, profiles, and dashboards right in the chat using MCP Apps. @@ -42,3 +133,60 @@ MCP Apps requires a host that supports the [MCP Apps extension](https://modelcon - **VS Code Insiders** — enable via the `chat.mcp.apps.enabled` setting - **Visual Studio Code** — enable via the `chat.mcp.apps.enabled` setting + +--- + +## CSV output for list tools + +CSV output mode returns supported list tool responses as CSV instead of JSON. This is intended to reduce response context for agents when scanning or summarising lists of GitHub data. + +CSV output applies only to tools in default toolsets whose names start with `list_`, such as `list_issues`, `list_pull_requests`, `list_commits`, and `list_branches`. It does not add new tools or expose a tool argument for selecting the format; the server controls the response format through the Insiders feature flag. + +### Format + +- Nested objects are flattened into dot-notation columns, for example `user.login`, `category.name`, or `head.ref`. +- Arrays are represented as compact single-cell values joined with `;`. +- `body` fields are whitespace-normalized so multiline Markdown does not expand a list response into many output lines. +- Response metadata present in wrapped responses, such as `pageInfo.*` and `totalCount`, is emitted as `#`-prefixed lines before the CSV rows, followed by a blank line. Tools that return a root JSON array do not include metadata preamble lines. + +### Enabling CSV output + +CSV output is enabled by Insiders Mode. For local development, it can also be enabled explicitly with the `csv_output` feature flag: + +```bash +github-mcp-server stdio --features csv_output +``` + +Because this changes list tool response shape, clients that require JSON list responses should avoid enabling this feature. + +--- + +## How feature flags are resolved + +> [!NOTE] +> This section is for contributors. End users only need the table at the top of this page. + +Insiders is a **meta feature flag** — the same shape as `default` or `all` for toolsets. It expands once at startup into a curated set of individual feature flags, and from that point on every code path keys off concrete flags, never `InsidersMode` directly. New experimental work should always get its own flag and then be added to the insiders expansion list, never folded into `insiders` as a catch-all. + +### Resolution order + +1. **User input.** Users may opt into specific features: + - Local server: `--features=,` CLI flag (or `GITHUB_FEATURES` env var). + - Self-hosted HTTP server: `X-MCP-Features: ,` request header. +2. **Allowlist filter.** User-supplied flags are filtered against [`AllowedFeatureFlags`](../pkg/github/feature_flags.go). Anything not on the allowlist is silently dropped — flags missing from the allowlist can only be turned on by remote-server feature management, not by end users. +3. **Insiders expansion.** If insiders mode is on (`--insiders`, `/insiders` route, or `X-MCP-Insiders: true`), every flag in [`InsidersFeatureFlags`](../pkg/github/feature_flags.go) is unioned in. The insiders expansion is **not** re-validated against the allowlist — insiders is a server-controlled switch that can reach internal-only flags. +4. **Server-side fallback (remote server only).** Any flag not yet decided falls back to the remote server's feature manager, which can roll a feature out independently of user input or insiders membership. + +`AllowedFeatureFlags` and `InsidersFeatureFlags` are deliberately independent sets: + +- A flag in **`AllowedFeatureFlags` only** is a regular opt-in: users can turn it on, but insiders does not auto-enable it. Granular issues/PRs flags work this way. +- A flag in **`InsidersFeatureFlags` only** is reachable through insiders (and remote-server rollouts), but cannot be enabled by user input. Internal-only experiments work this way. +- A flag in **both** is opt-in for end users *and* automatically on under insiders. + +### Adding a new feature flag + +1. Add a constant in `pkg/github/feature_flags.go`. +2. Add it to `AllowedFeatureFlags` if end users should be able to opt in via `--features` / `X-MCP-Features`. +3. Add it to `InsidersFeatureFlags` if insiders mode should turn it on automatically. +4. Gate the behavior on the concrete flag (`deps.IsFeatureEnabled(ctx, FeatureFlagX)`), never on `cfg.InsidersMode`. There is a `TestGitHubPackageDoesNotReadInsidersMode` guard test that fails if `pkg/github` reads `InsidersMode` directly. +5. The MCP-diff CI workflow picks up new entries in `AllowedFeatureFlags` automatically — see `.github/workflows/mcp-diff.yml`. diff --git a/docs/installation-guides/README.md b/docs/installation-guides/README.md index ab3aede36e..46581aa77e 100644 --- a/docs/installation-guides/README.md +++ b/docs/installation-guides/README.md @@ -6,13 +6,16 @@ This directory contains detailed installation instructions for the GitHub MCP Se - **[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 +- **[Claude Applications](install-claude.md)** - Installation guide for Claude Desktop and Claude Code CLI - **[Cline](install-cline.md)** - Installation guide for Cline - **[Cursor](install-cursor.md)** - Installation guide for Cursor IDE - **[Google Gemini CLI](install-gemini-cli.md)** - Installation guide for Google Gemini CLI - **[OpenAI Codex](install-codex.md)** - Installation guide for OpenAI Codex +- **[OpenCode](install-opencode.md)** - Installation guide for the OpenCode terminal agent - **[Roo Code](install-roo-code.md)** - Installation guide for Roo Code - **[Windsurf](install-windsurf.md)** - Installation guide for Windsurf IDE +- **[Xcode (Codex & Claude Agent)](install-xcode.md)** - Installation guide for Codex and Claude Agent within Xcode +- **[Zed](install-zed.md)** - Installation guide for Zed editor ## Support by Host Application @@ -28,10 +31,14 @@ This directory contains detailed installation instructions for the GitHub MCP Se | Cline | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Cursor | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Google Gemini CLI | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | +| OpenCode | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Roo Code | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Windsurf | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | +| Zed | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Copilot in Xcode | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: Copilot for Xcode 0.41.0+ | Easy | | Copilot in Eclipse | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: Eclipse Plug-in for Copilot 0.10.0+ | Easy | +| Xcode (Codex) | ✅ | ✅ PAT + ❌ No OAuth | Local: Docker (full path required), GitHub PAT
Remote: GitHub PAT via `GITHUB_PAT_TOKEN` env var (`bearer_token_env_var`) | Easy | +| Xcode (Claude Agent) | ✅ | ✅ PAT + ❌ No OAuth | Local: Docker (full path required), GitHub PAT
Remote: GitHub PAT | Easy | **Legend:** - ✅ = Fully supported @@ -101,6 +108,5 @@ If you encounter issues: After installation, you may want to explore: - **Toolsets**: Enable/disable specific GitHub API capabilities - **Read-Only Mode**: Restrict to read-only operations -- **Dynamic Tool Discovery**: Enable tools on-demand - **Lockdown Mode**: Hide public issue details created by users without push access diff --git a/docs/installation-guides/install-claude.md b/docs/installation-guides/install-claude.md index 05e3c3739d..d66b34776b 100644 --- a/docs/installation-guides/install-claude.md +++ b/docs/installation-guides/install-claude.md @@ -37,9 +37,17 @@ echo -e ".env\n.mcp.json" >> .gitignore claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer YOUR_GITHUB_PAT"}}' ``` -With an environment variable: +With an environment variable (Linux/macOS): ```bash -claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer '"$(grep GITHUB_PAT .env | cut -d '=' -f2)"'"}}' +export GITHUB_PAT="$(grep '^GITHUB_PAT=' .env | cut -d '=' -f2-)" +claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer '"$GITHUB_PAT"'"}}' +``` + +With an environment variable (Windows PowerShell): +```powershell +$githubPatLine = Get-Content .env | Select-String "^\s*GITHUB_PAT\s*=" | Select-Object -First 1 +$env:GITHUB_PAT = ($githubPatLine.Line -split "=", 2)[1].Trim().Trim('"').Trim("'") +claude mcp add-json github "{`"type`":`"http`",`"url`":`"https://api.githubcopilot.com/mcp`",`"headers`":{`"Authorization`":`"Bearer $env:GITHUB_PAT`"}}" ``` > **About the `--scope` flag** (optional): Use this to specify where the configuration is stored: @@ -164,7 +172,75 @@ Add this codeblock to your `claude_desktop_config.json`: --- -## Troubleshooting +## Xcode (Claude Agent) + +Xcode's Claude Agent uses the same `.claude.json` configuration format as the Claude Code CLI, but reads it from an Xcode-specific directory rather than the global config location. + +### Configuration File Location + +``` +~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/.claude.json +``` + +> Configurations placed here only affect Claude Agent when launched from Xcode. See [Apple's documentation](https://developer.apple.com/documentation/xcode/setting-up-coding-intelligence#Customize-the-Claude-Agent-and-Codex-environments) for more details. + +### Remote Server Setup (Recommended) + +Run the following command in Terminal to add the remote GitHub MCP server: + +```bash +claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp/","headers":{"Authorization":"Bearer YOUR_GITHUB_PAT"}}' --config ~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/.claude.json +``` + +Or open the file in a text editor and add the `mcpServers` block manually: + +```json +{ + "mcpServers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer YOUR_GITHUB_PAT" + } + } + } +} +``` + +### Local Server Setup (Docker) + +> **macOS note**: Xcode runs with a minimal `PATH` that typically excludes `/usr/local/bin` (Intel) and `/opt/homebrew/bin` (Apple Silicon). Use the full path to `docker` to ensure it can be found. Run `which docker` in Terminal to find the correct path on your system. + +```json +{ + "mcpServers": { + "github": { + "command": "/usr/local/bin/docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT" + } + } + } +} +``` + +### Setup Steps +1. Create or open `~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/.claude.json` +2. Add the configuration block above +3. Replace `YOUR_GITHUB_PAT` with your actual token +4. Restart Xcode + +--- + **Authentication Failed:** - Verify PAT has `repo` scope diff --git a/docs/installation-guides/install-codex.md b/docs/installation-guides/install-codex.md index 5f92996bc2..af24445882 100644 --- a/docs/installation-guides/install-codex.md +++ b/docs/installation-guides/install-codex.md @@ -20,10 +20,12 @@ bearer_token_env_var = "GITHUB_PAT_TOKEN" You can also add it via the Codex CLI: -```cli -codex mcp add github --url https://api.githubcopilot.com/mcp/ +```bash +codex mcp add github --url https://api.githubcopilot.com/mcp/ --bearer-token-env-var GITHUB_PAT_TOKEN ``` +The `--bearer-token-env-var` option is required for PAT-authenticated access to the hosted GitHub MCP server. +
Storing Your PAT Securely
diff --git a/docs/installation-guides/install-opencode.md b/docs/installation-guides/install-opencode.md new file mode 100644 index 0000000000..10e0e2db2a --- /dev/null +++ b/docs/installation-guides/install-opencode.md @@ -0,0 +1,154 @@ +# Install GitHub MCP Server in OpenCode + +[OpenCode](https://opencode.ai) is a terminal-based AI coding agent that exposes MCP servers under the `mcp` key in `opencode.json` (or `opencode.jsonc`). For general setup information (prerequisites, Docker installation, security best practices), see the [Installation Guides README](./README.md). + +## Prerequisites + +1. OpenCode installed (`brew install sst/tap/opencode` or see [OpenCode install docs](https://opencode.ai/docs/)) +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 + +> [!IMPORTANT] +> The OpenCode docs note that the GitHub MCP server can add a lot of tokens to your context. Consider limiting toolsets — for example, by setting `X-MCP-Toolsets` on the remote server or `--toolsets` on the local server — to keep prompts within your model's context window. See the [Server Configuration Guide](../server-configuration.md) and the [main README's toolsets section](../../README.md#available-toolsets). + +## Remote Server (Recommended) + +Uses GitHub's hosted server at `https://api.githubcopilot.com/mcp/`. Edit your [OpenCode config](https://opencode.ai/docs/config/) (typically `~/.config/opencode/opencode.json`, or `opencode.json` in your project root) and add the following under `mcp`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "github": { + "type": "remote", + "url": "https://api.githubcopilot.com/mcp/", + "enabled": true, + "oauth": false, + "headers": { + "Authorization": "Bearer YOUR_GITHUB_PAT" + } + } + } +} +``` + +Replace `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens). The `oauth: false` setting disables OpenCode's automatic OAuth discovery and tells it to use the PAT in `Authorization` instead — without this, OpenCode may try the OAuth flow first. + +### Using an environment variable for the PAT + +OpenCode supports environment-variable interpolation in config values via `{env:VAR_NAME}`. To avoid putting your PAT directly in `opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "github": { + "type": "remote", + "url": "https://api.githubcopilot.com/mcp/", + "enabled": true, + "oauth": false, + "headers": { + "Authorization": "Bearer {env:GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} +``` + +Set `GITHUB_PERSONAL_ACCESS_TOKEN` in your shell environment before starting OpenCode. + +## Local Server (Docker) + +The local GitHub MCP server runs via Docker and requires Docker Desktop (or another Docker runtime) to be installed and running. + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "github": { + "type": "local", + "command": [ + "docker", "run", "-i", "--rm", + "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "enabled": true, + "environment": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT" + } + } + } +} +``` + +> [!IMPORTANT] +> OpenCode expects `command` as a **single array** combining the executable and its arguments (e.g. `["docker", "run", "-i", ...]`), and the env-var key is `environment` (not `env`). This differs from hosts like Zed and Cursor. + +## Verify Installation + +1. Restart OpenCode (or start a new session). +2. Check that the server is discovered: + ```sh + opencode mcp list + ``` +3. Try a prompt that references the server by name to bias the model toward its tools: + ``` + Use the github tool to list my recently merged pull requests. + ``` + +## Managing the Server + +OpenCode exposes a few useful subcommands for MCP servers: + +| Command | Purpose | +| --- | --- | +| `opencode mcp list` | List configured MCP servers and their auth/connection status. | +| `opencode mcp debug github` | Show auth status, test HTTP connectivity, and walk through OAuth discovery for the `github` server. | +| `opencode mcp auth github` | Trigger an OAuth flow manually (only relevant if `oauth` is not set to `false`). | +| `opencode mcp logout github` | Clear stored OAuth tokens for the server. | + +## Disabling Tools Per-Agent + +Because the GitHub MCP server can register a large number of tools, you may want to **disable them globally** and **re-enable them only for specific agents**. OpenCode uses the `_*` glob pattern to match all tools from a server: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "github": { + "type": "remote", + "url": "https://api.githubcopilot.com/mcp/", + "enabled": true, + "oauth": false, + "headers": { "Authorization": "Bearer {env:GITHUB_PERSONAL_ACCESS_TOKEN}" } + } + }, + "tools": { + "github_*": false + }, + "agent": { + "github-helper": { + "tools": { "github_*": true } + } + } +} +``` + +This pattern is recommended by the [OpenCode MCP docs](https://opencode.ai/docs/mcp-servers/) for servers with many tools. + +## Troubleshooting + +- **`401 Unauthorized` from the remote server**: confirm your PAT is valid and not expired. If you set `oauth: false`, OpenCode will not attempt an OAuth fallback — the `Authorization` header must be correct. +- **Server marked failed in `opencode mcp list`**: run `opencode mcp debug github` to see the exact connectivity and auth diagnostics. +- **Tools missing from prompts**: check that `enabled: true` is set on the server and that you have not disabled `github_*` in your `tools` block without re-enabling it for the current agent. +- **Context window exceeded**: the GitHub MCP server can register many tools. Use server-side toolset filtering (`X-MCP-Toolsets` header) to register only the toolsets you need. +- **Docker errors on the local server**: ensure Docker Desktop is running and the `ghcr.io/github/github-mcp-server` image has been pulled (`docker pull ghcr.io/github/github-mcp-server`). + +## Important Notes + +- **Configuration key**: OpenCode uses `mcp` (not `mcpServers` or `context_servers`). +- **Type discriminator**: every entry must include `"type": "local"` or `"type": "remote"`. +- **Command shape**: `command` is a single array combining the executable and its arguments. +- **Environment variable key**: `environment` (not `env`). +- **OAuth**: enabled by default for remote servers. Set `"oauth": false` when using PAT-in-`Authorization`, otherwise OpenCode may try OAuth first. +- **Env interpolation**: use `{env:VAR_NAME}` in string values to read from the shell environment instead of hard-coding secrets. diff --git a/docs/installation-guides/install-xcode.md b/docs/installation-guides/install-xcode.md new file mode 100644 index 0000000000..15bcfde34f --- /dev/null +++ b/docs/installation-guides/install-xcode.md @@ -0,0 +1,45 @@ +# Install GitHub MCP Server in Xcode + +Xcode currently supports two built-in coding agents: **Codex** (powered by OpenAI) and **Claude Agent** (powered by Anthropic). Follow the standard installation guide for each agent, with one important difference: Xcode uses its own isolated configuration directories for each agent, separate from your global config. + +> Configurations placed in these directories only affect agents when launched from Xcode. See [Apple's documentation](https://developer.apple.com/documentation/xcode/setting-up-coding-intelligence#Customize-the-Claude-Agent-and-Codex-environments) for more details. + +## Configuration Directories + +| Agent | Configuration Directory | +|-------|------------------------| +| Codex | `~/Library/Developer/Xcode/CodingAssistant/codex/` | +| Claude Agent | `~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/` | + +Place your MCP server configuration in the relevant directory above rather than the default location used by the standalone CLI. + +## Setup Guides + +- **[Codex](install-codex.md)** — configure `config.toml` inside `~/Library/Developer/Xcode/CodingAssistant/codex/` +- **[Claude Agent](install-claude.md#xcode-claude-agent)** — configure `.claude.json` inside `~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/` + +## macOS Path Note + +Xcode runs with a minimal `PATH` that typically excludes common binary locations. If you are using a local STDIO server (e.g. Docker or a pre-built binary), use the **full path** to the command in your config. Run `which docker` (or `which github-mcp-server`) in Terminal to find the correct path on your system. Common locations: + +| Installation | Typical path | +|---|---| +| Docker (Intel Mac) | `/usr/local/bin/docker` | +| Docker (Apple Silicon) | `/usr/local/bin/docker` | +| Homebrew (Intel Mac) | `/usr/local/bin/` | +| Homebrew (Apple Silicon) | `/opt/homebrew/bin/` | + +## Troubleshooting + +| Issue | Possible Cause | Fix | +|-------|----------------|-----| +| Tools not loading | Config placed in wrong directory | Ensure config is in the Xcode-specific path above, not `~/.codex/` or `~/.claude.json` | +| Command not found (STDIO) | Xcode's PATH excludes binary location | Use the full path (e.g. `/usr/local/bin/docker` or `/opt/homebrew/bin/docker`); run `which docker` in Terminal to confirm | +| Docker not found | Docker not running | Start Docker Desktop and restart Xcode | +| Authentication failed | Invalid or expired PAT | Regenerate PAT and update config | + +## References + +- [Apple Developer Documentation — Setting up coding intelligence](https://developer.apple.com/documentation/xcode/setting-up-coding-intelligence#Customize-the-Claude-Agent-and-Codex-environments) +- [Codex MCP documentation](https://developers.openai.com/codex/mcp) +- Main project README: [Advanced configuration options](../../README.md) diff --git a/docs/installation-guides/install-zed.md b/docs/installation-guides/install-zed.md new file mode 100644 index 0000000000..d0e07b6d8e --- /dev/null +++ b/docs/installation-guides/install-zed.md @@ -0,0 +1,103 @@ +# Install GitHub MCP Server in Zed + +[Zed](https://zed.dev) is a high-performance multiplayer code editor with native MCP support. Zed exposes MCP servers under the `context_servers` settings key. For general setup information (prerequisites, Docker installation, security best practices), see the [Installation Guides README](./README.md). + +## Prerequisites + +1. Zed installed (latest version — Zed v0.224.0+ recommended for the modern `agent.tool_permissions` settings shape) +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 + +## Installation Methods + +There are two ways to install the GitHub MCP server in Zed: + +- **Option A — Zed Extension (easiest):** a community-maintained [GitHub MCP extension](https://zed.dev/extensions/mcp-server-github) is available in the Zed extension gallery. Install it from the Agent Panel's top-right menu → "View Server Extensions", or from the command palette via the `zed: extensions` action. After installation, Zed pops up a modal asking for your GitHub Personal Access Token. +- **Option B — Custom Server (recommended for the official remote endpoint):** add the configuration manually to `settings.json` to use either GitHub's hosted remote server or the official Docker image directly. The rest of this guide covers Option B. + +## Remote Server (Recommended) + +Uses GitHub's hosted server at `https://api.githubcopilot.com/mcp/`. Open your Zed [settings file](https://zed.dev/docs/configuring-zed.html#settings-files) (Command Palette → `zed: open settings`) and add the configuration below under `context_servers`. + +```json +{ + "context_servers": { + "github": { + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer YOUR_GITHUB_PAT" + } + } + } +} +``` + +Replace `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens). To customize toolsets, add server-side headers like `X-MCP-Toolsets` or `X-MCP-Readonly` to the `headers` object — see the [Server Configuration Guide](../server-configuration.md). + +> [!NOTE] +> If you omit the `Authorization` header, Zed will attempt the standard MCP OAuth flow on first use. The GitHub MCP server does not currently advertise OAuth for non-Copilot hosts, so a Personal Access Token in the `Authorization` header is the supported path. + +## Local Server (Docker) + +The local GitHub MCP server runs via Docker and requires Docker Desktop (or another Docker runtime) to be installed and running. + +```json +{ + "context_servers": { + "github": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT" + } + } + } +} +``` + +> [!IMPORTANT] +> Zed expects `command` as a **string** plus a separate `args` array, not a single array combining both. This differs from hosts like OpenCode and Claude Desktop. + +## Verify Installation + +1. Open the Agent Panel and click into its Settings view (or run `agent: open settings`). +2. Find `github` in the context servers list. A green indicator dot with the tooltip "Server is active" confirms a working configuration. Other colors and tooltip messages indicate startup or auth errors. +3. Try a prompt that should invoke a tool — for example, `List my recent GitHub pull requests`. Zed will prompt for tool approval before the first call unless your `agent.tool_permissions.default` is set to `"allow"`. + +## Tool Permissions (Optional) + +Zed v0.224.0+ controls tool approval via `agent.tool_permissions`. Approve a specific GitHub MCP tool without per-call prompts by using the `mcp::` key format: + +```json +{ + "agent": { + "tool_permissions": { + "default": "confirm", + "rules": [ + { "tool": "mcp:github:list_pull_requests", "permission": "allow" }, + { "tool": "mcp:github:list_issues", "permission": "allow" } + ] + } + } +} +``` + +See the [Zed tool permissions docs](https://zed.dev/docs/ai/tool-permissions.html) for the full schema. + +## Troubleshooting + +- **Server indicator stays red / "Server is not running"**: check the Agent Panel's settings view for the per-server error string. Most common cause is invalid JSON in `settings.json` — Zed surfaces JSON parse errors in the editor itself. +- **`401 Unauthorized`**: verify your PAT has not expired and includes the scopes for the tools you intend to call. The remote endpoint will reject requests with no `Authorization` header (no anonymous access). +- **Tools missing from prompts**: confirm the Agent profile in use has not disabled the server. If you're using a [custom profile](https://zed.dev/docs/ai/agent-panel.html#custom-profiles), make sure `enable_all_context_servers` is `true` or that `github` is explicitly listed. +- **Docker errors on the local server**: ensure Docker Desktop is running and the `ghcr.io/github/github-mcp-server` image has been pulled at least once. Try `docker pull ghcr.io/github/github-mcp-server` from a terminal. + +## Important Notes + +- **Configuration key**: Zed uses `context_servers` (not `mcpServers`). +- **Command shape**: `command` is a string + separate `args` array. +- **OAuth**: omitting `Authorization` triggers Zed's MCP OAuth flow, but the GitHub MCP server's PAT-based auth is the supported path today. +- **External agents**: MCP servers configured in `context_servers` are forwarded to [external agents](https://zed.dev/docs/ai/external-agents.html) via the Agent Client Protocol. diff --git a/docs/remote-server.md b/docs/remote-server.md index 5a82f1c2e1..aa083d2f29 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,7 +19,8 @@ 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) | +| apps
`default` | Default toolset | 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) | +| apps
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/x/all | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/all/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%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) | | 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) | diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 87d48e01e3..2342664c3a 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -11,9 +11,9 @@ We currently support the following ways in which the GitHub MCP Server can be co | Individual Tools | `X-MCP-Tools` header | `--tools` flag or `GITHUB_TOOLS` env var | | Exclude Tools | `X-MCP-Exclude-Tools` header | `--exclude-tools` flag or `GITHUB_EXCLUDE_TOOLS` env var | | 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 | | Insiders Mode | `X-MCP-Insiders` header or `/insiders` URL | `--insiders` flag or `GITHUB_INSIDERS` env var | +| Feature Flags | `X-MCP-Features` header | `--features` flag | | Scope Filtering | Always enabled | Always enabled | | Server Name/Title | Not available | `GITHUB_MCP_SERVER_NAME` / `GITHUB_MCP_SERVER_TITLE` env vars or `github-mcp-server-config.json` | @@ -23,7 +23,7 @@ We currently support the following ways in which the GitHub MCP Server can be co ## How Configuration Works -All configuration options are **composable**: you can combine toolsets, individual tools, excluded tools, dynamic discovery, read-only mode and lockdown mode in any way that suits your workflow. +All configuration options are **composable**: you can combine toolsets, individual tools, excluded tools, read-only mode and lockdown mode in any way that suits your workflow. Note: **read-only** mode acts as a strict security filter that takes precedence over any other configuration, by disabling write tools even when explicitly requested. @@ -286,34 +286,31 @@ When active, this mode will disable all tools that are not read-only even if the --- -### Dynamic Discovery (Local Only) +### Lockdown Mode -**Best for:** Letting the LLM discover and enable toolsets as needed. +**Best for:** Public repositories where you want to limit content from users without push access. -Starts with only discovery tools (`enable_toolset`, `list_available_toolsets`, `get_toolset_tools`), then expands on demand. +Lockdown mode ensures the server only surfaces content in public repositories from users with push access to that repository. Private repositories are unaffected, and collaborators retain full access to their own content. +**Example:** - + +
Local Server Only
Remote ServerLocal Server
```json { - "type": "stdio", - "command": "go", - "args": [ - "run", - "./cmd/github-mcp-server", - "stdio", - "--dynamic-toolsets" - ], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "X-MCP-Lockdown": "true" } } ``` -**With some tools pre-enabled:** + + ```json { "type": "stdio", @@ -322,8 +319,7 @@ Starts with only discovery tools (`enable_toolset`, `list_available_toolsets`, ` "run", "./cmd/github-mcp-server", "stdio", - "--dynamic-toolsets", - "--tools=get_me,search_code" + "--lockdown-mode" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" @@ -335,28 +331,34 @@ Starts with only discovery tools (`enable_toolset`, `list_available_toolsets`, `
-When both dynamic mode and specific tools are enabled in the server configuration, the server will start with the 3 dynamic tools + the specified tools. - --- -### Lockdown Mode +### Insiders Mode -**Best for:** Public repositories where you want to limit content from users without push access. +**Best for:** Users who want early access to experimental features and new tools before they reach general availability. -Lockdown mode ensures the server only surfaces content in public repositories from users with push access to that repository. Private repositories are unaffected, and collaborators retain full access to their own content. +Insiders Mode unlocks experimental features, such as [MCP Apps](#mcp-apps) support. We created this mode to have a way to roll out experimental features and collect feedback. So if you are using Insiders, please don't hesitate to share your feedback with us! Features in Insiders Mode may change, evolve, or be removed based on user feedback. -**Example:**
Remote ServerLocal Server
+**Option A: URL path** +```json +{ + "type": "http", + "url": "https://api.githubcopilot.com/mcp/insiders" +} +``` + +**Option B: Header** ```json { "type": "http", "url": "https://api.githubcopilot.com/mcp/", "headers": { - "X-MCP-Lockdown": "true" + "X-MCP-Insiders": "true" } } ``` @@ -372,7 +374,7 @@ Lockdown mode ensures the server only surfaces content in public repositories fr "run", "./cmd/github-mcp-server", "stdio", - "--lockdown-mode" + "--insiders" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" @@ -384,34 +386,37 @@ Lockdown mode ensures the server only surfaces content in public repositories fr
+See [Insiders Features](./insiders-features.md) for a full list of what's available in Insiders Mode. + --- -### Insiders Mode +### MCP Apps -**Best for:** Users who want early access to experimental features and new tools before they reach general availability. +[MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) is an extension to the Model Context Protocol that enables servers to deliver interactive user interfaces to end users. Instead of returning plain text that the LLM must interpret and relay, tools can render forms, profiles, and dashboards right in the chat. + +MCP Apps is enabled by [Insiders Mode](#insiders-mode), or independently via the `remote_mcp_ui_apps` feature flag. -Insiders Mode unlocks experimental features, such as [MCP Apps](./insiders-features.md#mcp-apps) support. We created this mode to have a way to roll out experimental features and collect feedback. So if you are using Insiders, please don't hesitate to share your feedback with us! Features in Insiders Mode may change, evolve, or be removed based on user feedback. +**Supported tools:** + +| Tool | Description | +|------|-------------| +| `get_me` | Displays your GitHub user profile with avatar, bio, and stats in a rich card | +| `issue_write` | Opens an interactive form to create or update issues | +| `create_pull_request` | Provides a full PR creation form to create a pull request (or a draft pull request) | + +**Client requirements:** MCP Apps requires a host that supports the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps). Currently tested with VS Code (`chat.mcp.apps.enabled` setting).
Remote ServerLocal Server
-**Option A: URL path** -```json -{ - "type": "http", - "url": "https://api.githubcopilot.com/mcp/insiders" -} -``` - -**Option B: Header** ```json { "type": "http", "url": "https://api.githubcopilot.com/mcp/", "headers": { - "X-MCP-Insiders": "true" + "X-MCP-Features": "remote_mcp_ui_apps" } } ``` @@ -427,7 +432,7 @@ Insiders Mode unlocks experimental features, such as [MCP Apps](./insiders-featu "run", "./cmd/github-mcp-server", "stdio", - "--insiders" + "--features=remote_mcp_ui_apps" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" @@ -439,8 +444,6 @@ Insiders Mode unlocks experimental features, such as [MCP Apps](./insiders-featu
-See [Insiders Features](./insiders-features.md) for a full list of what's available in Insiders Mode. - --- ### Scope Filtering @@ -464,7 +467,6 @@ See [Scope Filtering](./scope-filtering.md) for details on how filtering works w | Server fails to start | Invalid tool name in `--tools` or `X-MCP-Tools` | Check tool name spelling; use exact names from [Tools list](../README.md#tools) | | Write tools not working | Read-only mode enabled | Remove `--read-only` flag or `X-MCP-Readonly` header | | Tools missing | Toolset not enabled | Add the required toolset or specific tool | -| Dynamic tools not available | Using remote server | Dynamic mode is available in the local MCP server only | --- diff --git a/docs/streamable-http.md b/docs/streamable-http.md index 0a11c5ea76..8f4a2bff84 100644 --- a/docs/streamable-http.md +++ b/docs/streamable-http.md @@ -59,6 +59,18 @@ The OAuth protected resource metadata's `resource` attribute will be populated w This allows OAuth clients to discover authentication requirements and endpoint information automatically. +### Behind a Trusted Proxy (advanced) + +By default, the server ignores the `X-Forwarded-Host` and `X-Forwarded-Proto` headers when constructing OAuth resource metadata URLs, so an untrusted client cannot influence the URL advertised to MCP clients. For most deployments, setting `--base-url` to the externally visible URL is the right approach. + +If the server sits behind an internal forwarder that you fully control (for example, an in-cluster gateway that needs to preserve the originating hostname per request), you can opt into honoring those headers: + +```bash +github-mcp-server http --trust-proxy-headers +``` + +Equivalent environment variable: `GITHUB_TRUST_PROXY_HEADERS=1`. Only enable this when the upstream proxy is trusted to set or strip these headers; otherwise prefer `--base-url`. When `--base-url` is set, it always takes precedence and `--trust-proxy-headers` has no effect. + ## Client Configuration ### Using OAuth Authentication diff --git a/docs/toolsets-and-icons.md b/docs/toolsets-and-icons.md index 9c26b4aa10..9228248ecb 100644 --- a/docs/toolsets-and-icons.md +++ b/docs/toolsets-and-icons.md @@ -161,7 +161,6 @@ icons := octicons.Icons("repo") | Labels | `tag` | | Stargazers | `star` | | Notifications | `bell` | -| Dynamic | `tools` | | Copilot | `copilot` | | Support Search | `book` | diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index ad40ecad02..73d5f271c9 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -18,7 +18,7 @@ import ( "github.com/github/github-mcp-server/internal/ghmcp" "github.com/github/github-mcp-server/pkg/github" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) diff --git a/go.mod b/go.mod index 2bacfe7593..080cdcfd8e 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,16 @@ module github.com/github/github-mcp-server -go 1.24.0 +go 1.25.0 require ( - github.com/go-chi/chi/v5 v5.2.5 + github.com/go-chi/chi/v5 v5.3.0 github.com/go-viper/mapstructure/v2 v2.5.0 - github.com/google/go-github/v82 v82.0.0 - github.com/google/jsonschema-go v0.4.2 - github.com/josephburnett/jd/v2 v2.4.0 + github.com/google/go-github/v87 v87.0.0 + github.com/google/jsonschema-go v0.4.3 + github.com/josephburnett/jd/v2 v2.5.0 github.com/lithammer/fuzzysearch v1.1.8 github.com/microcosm-cc/bluemonday v1.0.27 - github.com/modelcontextprotocol/go-sdk v1.3.1-0.20260220105450-b17143f71798 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 @@ -25,28 +25,24 @@ 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.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/segmentio/asm v1.1.3 // indirect - github.com/segmentio/encoding v0.5.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/net v0.38.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.28.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 80f153a82f..fbf06018f7 100644 --- a/go.sum +++ b/go.sum @@ -7,60 +7,55 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= -github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= 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.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/v82 v82.0.0 h1:OH09ESON2QwKCUVMYmMcVu1IFKFoaZHwqYaUtr/MVfk= -github.com/google/go-github/v82 v82.0.0/go.mod h1:hQ6Xo0VKfL8RZ7z1hSfB4fvISg0QqHOqe9BP0qo+WvM= +github.com/google/go-github/v87 v87.0.0 h1:9Ck3dcOxWJyfsN8tzdah4YvmqB/7ZsstMglv/PkOsl0= +github.com/google/go-github/v87 v87.0.0/go.mod h1:hGUoT5pwm/ck5uLL+wroSVQfg8mpe+buxllCcGV4VaM= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= -github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/josephburnett/jd/v2 v2.4.0 h1:8MDRpbs/CATx4FR6Px8YMSp6NPGtI8pUWtDrgqI74tI= -github.com/josephburnett/jd/v2 v2.4.0/go.mod h1:0I5+gbo7y8diuajJjm79AF44eqTheSJy1K7DSbIUFAQ= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/josephburnett/jd/v2 v2.5.0 h1:c1G9TXeozJINRGZDeN2Z000Ok2Z8+0h0rbBRSdF79CY= +github.com/josephburnett/jd/v2 v2.5.0/go.mod h1:G6F+v/jcqS0b0d6LIyi1xC+wLleSKN8HvrqBhmBC8b8= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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.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/modelcontextprotocol/go-sdk v1.3.1-0.20260220105450-b17143f71798 h1:ogb5ErmcnxZgfaTeVZnKEMrwdHDpJ3yln5EhCIPcTlY= -github.com/modelcontextprotocol/go-sdk v1.3.1-0.20260220105450-b17143f71798/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021/go.mod h1:WERUkUryfUWlrHnFSO/BEUZ+7Ns8aZy7iVOGewxKzcc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= -github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= -github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M= github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= @@ -91,8 +86,6 @@ 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-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= 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= @@ -101,8 +94,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug 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.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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= @@ -112,8 +105,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc 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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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= @@ -128,8 +121,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm 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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 5c4e7f6f1b..a37c4d940d 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -18,22 +18,25 @@ import ( "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/lockdown" mcplog "github.com/github/github-mcp-server/pkg/log" + "github.com/github/github-mcp-server/pkg/observability" + "github.com/github/github-mcp-server/pkg/observability/metrics" "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" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) // githubClients holds all the GitHub API clients created for a server instance. type githubClients struct { - rest *gogithub.Client - gql *githubv4.Client - gqlHTTP *http.Client // retained for middleware to modify transport - raw *raw.Client - repoAccess *lockdown.RepoAccessCache + rest *gogithub.Client + restUATransp *transport.UserAgentTransport + gql *githubv4.Client + gqlHTTP *http.Client // retained for middleware to modify transport + raw *raw.Client + repoAccess *lockdown.RepoAccessCache } // createGitHubClients creates all the GitHub API clients needed by the server. @@ -59,10 +62,18 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv } // Construct REST client - restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token) - restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version) - restClient.BaseURL = restURL - restClient.UploadURL = uploadURL + restUATransport := &transport.UserAgentTransport{ + Transport: http.DefaultTransport, + Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version), + } + restClient, err := gogithub.NewClient( + gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}), + gogithub.WithAuthToken(cfg.Token), + gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()), + ) + if err != nil { + return nil, fmt.Errorf("failed to create REST client: %w", err) + } // Construct GraphQL client // We use NewEnterpriseClient unconditionally since we already parsed the API host @@ -78,7 +89,10 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv gqlClient := githubv4.NewEnterpriseClient(graphQLURL.String(), gqlHTTPClient) // Create raw content client (shares REST client's HTTP transport) - rawClient := raw.NewClient(restClient, rawURL) + rawClient, err := raw.NewClient(restClient, rawURL) + if err != nil { + return nil, fmt.Errorf("failed to create raw client: %w", err) + } // Set up repo access cache for lockdown mode var repoAccessCache *lockdown.RepoAccessCache @@ -89,15 +103,16 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv if cfg.RepoAccessTTL != nil { opts = append(opts, lockdown.WithTTL(*cfg.RepoAccessTTL)) } - repoAccessCache = lockdown.GetInstance(gqlClient, opts...) + repoAccessCache = lockdown.NewRepoAccessCache(gqlClient, restClient, opts...) } return &githubClients{ - rest: restClient, - gql: gqlClient, - gqlHTTP: gqlHTTPClient, - raw: rawClient, - repoAccess: repoAccessCache, + rest: restClient, + restUATransp: restUATransport, + gql: gqlClient, + gqlHTTP: gqlHTTPClient, + raw: rawClient, + repoAccess: repoAccessCache, }, nil } @@ -112,10 +127,14 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se return nil, fmt.Errorf("failed to create GitHub clients: %w", err) } - // Create feature checker - featureChecker := createFeatureChecker(cfg.EnabledFeatures) + // Create feature checker — resolves explicit features + insiders expansion + featureChecker := createFeatureChecker(cfg.EnabledFeatures, cfg.InsidersMode) // Create dependencies for tool handlers + obs, err := observability.NewExporters(cfg.Logger, metrics.NewNoopMetrics()) + if err != nil { + return nil, fmt.Errorf("failed to create observability exporters: %w", err) + } deps := github.NewBaseDeps( clients.rest, clients.gql, @@ -124,21 +143,20 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se cfg.Translator, github.FeatureFlags{ LockdownMode: cfg.LockdownMode, - InsidersMode: cfg.InsidersMode, }, cfg.ContentWindowSize, featureChecker, + obs, ) // Build and register the tool/resource/prompt inventory inventoryBuilder := github.NewInventory(cfg.Translator). WithDeprecatedAliases(github.DeprecatedToolAliases). WithReadOnly(cfg.ReadOnly). - WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)). + WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)). WithTools(github.CleanTools(cfg.EnabledTools)). WithExcludeTools(cfg.ExcludeTools). WithServerInstructions(). - WithFeatureChecker(featureChecker). - WithInsidersMode(cfg.InsidersMode) + WithFeatureChecker(featureChecker) // Apply token scope filtering if scopes are known (for PAT filtering) if cfg.TokenScopes != nil { @@ -155,14 +173,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se return nil, fmt.Errorf("failed to create GitHub MCP server: %w", err) } - // Register MCP App UI resources if available (requires running script/build-ui). - // We check availability to allow Insiders mode to work for non-UI features - // even when UI assets haven't been built. - if cfg.InsidersMode && github.UIAssetsAvailable() { - github.RegisterUIResources(ghServer) - } - - ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.rest, clients.gqlHTTP)) + ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.restUATransp, clients.gqlHTTP)) return ghServer, nil } @@ -189,10 +200,6 @@ type StdioServerConfig struct { // Items with FeatureFlagEnable matching an entry in this list will be available EnabledFeatures []string - // Whether to enable dynamic toolsets - // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery - DynamicToolsets bool - // ReadOnly indicates if we should only register read-only tools ReadOnly bool @@ -212,7 +219,7 @@ type StdioServerConfig struct { // LockdownMode indicates if we should enable lockdown mode LockdownMode bool - // InsidersMode indicates if we should enable experimental features + // InsidersMode expands to the curated set of feature flags enabled for insiders. InsidersMode bool // ExcludeTools is a list of tool names to disable regardless of other settings. @@ -246,7 +253,7 @@ func RunStdioServer(cfg StdioServerConfig) error { slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo}) } logger := slog.New(slogHandler) - logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) + logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "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. @@ -271,7 +278,6 @@ func RunStdioServer(cfg StdioServerConfig) error { EnabledToolsets: cfg.EnabledToolsets, EnabledTools: cfg.EnabledTools, EnabledFeatures: cfg.EnabledFeatures, - DynamicToolsets: cfg.DynamicToolsets, ReadOnly: cfg.ReadOnly, Translator: t, ContentWindowSize: cfg.ContentWindowSize, @@ -327,21 +333,17 @@ func RunStdioServer(cfg StdioServerConfig) error { return nil } -// createFeatureChecker returns a FeatureFlagChecker that checks if a flag name -// is present in the provided list of enabled features. For the local server, -// this is populated from the --features CLI flag. -func createFeatureChecker(enabledFeatures []string) inventory.FeatureFlagChecker { - // Build a set for O(1) lookup - featureSet := make(map[string]bool, len(enabledFeatures)) - for _, f := range enabledFeatures { - featureSet[f] = true - } +// createFeatureChecker returns a FeatureFlagChecker that resolves features +// using the centralized ResolveFeatureFlags function. For the local server, +// features are resolved once at startup from --features CLI flag and insiders mode. +func createFeatureChecker(enabledFeatures []string, insidersMode bool) inventory.FeatureFlagChecker { + featureSet := github.ResolveFeatureFlags(enabledFeatures, insidersMode) return func(_ context.Context, flagName string) (bool, error) { return featureSet[flagName], nil } } -func addUserAgentsMiddleware(cfg github.MCPServerConfig, restClient *gogithub.Client, gqlHTTPClient *http.Client) func(next mcp.MethodHandler) mcp.MethodHandler { +func addUserAgentsMiddleware(cfg github.MCPServerConfig, restUATransp *transport.UserAgentTransport, gqlHTTPClient *http.Client) func(next mcp.MethodHandler) mcp.MethodHandler { return func(next mcp.MethodHandler) mcp.MethodHandler { return func(ctx context.Context, method string, request mcp.Request) (result mcp.Result, err error) { if method != "initialize" { @@ -364,7 +366,7 @@ func addUserAgentsMiddleware(cfg github.MCPServerConfig, restClient *gogithub.Cl userAgent += " (insiders)" } - restClient.UserAgent = userAgent + restUATransp.Agent = userAgent gqlHTTPClient.Transport = &transport.UserAgentTransport{ Transport: gqlHTTPClient.Transport, diff --git a/pkg/errors/error.go b/pkg/errors/error.go index d757651592..7c1f28e660 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -6,7 +6,7 @@ import ( "net/http" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go index e33d5bd39e..7459569f2a 100644 --- a/pkg/errors/error_test.go +++ b/pkg/errors/error_test.go @@ -6,7 +6,7 @@ import ( "net/http" "testing" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/__toolsnaps__/actions_run_trigger.snap b/pkg/github/__toolsnaps__/actions_run_trigger.snap index c51501c176..41a6439929 100644 --- a/pkg/github/__toolsnaps__/actions_run_trigger.snap +++ b/pkg/github/__toolsnaps__/actions_run_trigger.snap @@ -8,6 +8,7 @@ "properties": { "inputs": { "description": "Inputs the workflow accepts. Only used for 'run_workflow' method.", + "properties": {}, "type": "object" }, "method": { diff --git a/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap b/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap new file mode 100644 index 0000000000..1e27c5645e --- /dev/null +++ b/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap @@ -0,0 +1,75 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Add Pull Request Review Comment" + }, + "description": "Add a review comment to the current user's pending pull request review.", + "inputSchema": { + "properties": { + "body": { + "description": "The comment body", + "type": "string" + }, + "line": { + "description": "The line number in the diff to comment on (optional)", + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "path": { + "description": "The relative path of the file to comment on", + "type": "string" + }, + "pullNumber": { + "description": "The pull request number", + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "side": { + "description": "The side of the diff to comment on (optional)", + "enum": [ + "LEFT", + "RIGHT" + ], + "type": "string" + }, + "startLine": { + "description": "The start line of a multi-line comment (optional)", + "type": "number" + }, + "startSide": { + "description": "The start side of a multi-line comment (optional)", + "enum": [ + "LEFT", + "RIGHT" + ], + "type": "string" + }, + "subjectType": { + "description": "The subject type of the comment", + "enum": [ + "FILE", + "LINE" + ], + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "pullNumber", + "path", + "body", + "subjectType" + ], + "type": "object" + }, + "name": "add_pull_request_review_comment" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_sub_issue.snap b/pkg/github/__toolsnaps__/add_sub_issue.snap new file mode 100644 index 0000000000..ef9df400c6 --- /dev/null +++ b/pkg/github/__toolsnaps__/add_sub_issue.snap @@ -0,0 +1,41 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Add Sub-Issue" + }, + "description": "Add a sub-issue to a parent issue.", + "inputSchema": { + "properties": { + "issue_number": { + "description": "The parent issue number", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "replace_parent": { + "description": "If true, reparent the sub-issue if it already has a parent", + "type": "boolean" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "sub_issue_id": { + "description": "The ID of the sub-issue to add. ID is not the same as issue number", + "type": "number" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "sub_issue_id" + ], + "type": "object" + }, + "name": "add_sub_issue" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_issue.snap b/pkg/github/__toolsnaps__/create_issue.snap index d11c41c0ed..51923c47cc 100644 --- a/pkg/github/__toolsnaps__/create_issue.snap +++ b/pkg/github/__toolsnaps__/create_issue.snap @@ -1,35 +1,18 @@ { "annotations": { - "title": "Open new issue", - "readOnlyHint": false + "destructiveHint": false, + "openWorldHint": true, + "title": "Create Issue" }, - "description": "Create a new issue in a GitHub repository.", + "description": "Create a new issue in a GitHub repository with a title and optional body.", "inputSchema": { "properties": { - "assignees": { - "description": "Usernames to assign to this issue", - "items": { - "type": "string" - }, - "type": "array" - }, "body": { - "description": "Issue body content", + "description": "Issue body content (optional)", "type": "string" }, - "labels": { - "description": "Labels to apply to this issue", - "items": { - "type": "string" - }, - "type": "array" - }, - "milestone": { - "description": "Milestone number", - "type": "number" - }, "owner": { - "description": "Repository owner", + "description": "Repository owner (username or organization)", "type": "string" }, "repo": { @@ -39,10 +22,6 @@ "title": { "description": "Issue title", "type": "string" - }, - "type": { - "description": "Type of this issue", - "type": "string" } }, "required": [ diff --git a/pkg/github/__toolsnaps__/create_pull_request_review.snap b/pkg/github/__toolsnaps__/create_pull_request_review.snap new file mode 100644 index 0000000000..1986b2cfff --- /dev/null +++ b/pkg/github/__toolsnaps__/create_pull_request_review.snap @@ -0,0 +1,49 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Create Pull Request Review" + }, + "description": "Create a review on a pull request. If event is provided, the review is submitted immediately; otherwise a pending review is created.", + "inputSchema": { + "properties": { + "body": { + "description": "The review body text (optional)", + "type": "string" + }, + "commitID": { + "description": "The SHA of the commit to review (optional, defaults to latest)", + "type": "string" + }, + "event": { + "description": "The review action to perform. If omitted, creates a pending review.", + "enum": [ + "APPROVE", + "REQUEST_CHANGES", + "COMMENT" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "pullNumber": { + "description": "The pull request number", + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "pullNumber" + ], + "type": "object" + }, + "name": "create_pull_request_review" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_pending_pull_request_review.snap b/pkg/github/__toolsnaps__/delete_pending_pull_request_review.snap new file mode 100644 index 0000000000..b457e415a8 --- /dev/null +++ b/pkg/github/__toolsnaps__/delete_pending_pull_request_review.snap @@ -0,0 +1,32 @@ +{ + "annotations": { + "destructiveHint": true, + "openWorldHint": true, + "title": "Delete Pending Pull Request Review" + }, + "description": "Delete a pending pull request review.", + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "pullNumber": { + "description": "The pull request number", + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "pullNumber" + ], + "type": "object" + }, + "name": "delete_pending_pull_request_review" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/discussion_comment_write.snap b/pkg/github/__toolsnaps__/discussion_comment_write.snap new file mode 100644 index 0000000000..5edadfaeaa --- /dev/null +++ b/pkg/github/__toolsnaps__/discussion_comment_write.snap @@ -0,0 +1,48 @@ +{ + "annotations": { + "destructiveHint": true, + "title": "Manage discussion comments" + }, + "description": "Write operations for discussion comments.\nSupports adding top-level comments, replying to existing comments, updating comment content, deleting comments, and marking or unmarking comments as the answer.", + "inputSchema": { + "properties": { + "body": { + "description": "Comment content (required for 'add', 'reply', and 'update' methods)", + "type": "string" + }, + "commentNodeID": { + "description": "The Node ID of the discussion comment (required for 'reply', 'update', 'delete', 'mark_answer', and 'unmark_answer' methods). For 'reply', this is the top-level comment to reply to; GitHub Discussions only support one level of nesting.", + "type": "string" + }, + "discussionNumber": { + "description": "Discussion number (required for 'add' and 'reply' methods)", + "type": "number" + }, + "method": { + "description": "Write operation to perform on a discussion comment.\nOptions are:\n- 'add' - adds a new top-level comment to a discussion.\n- 'reply' - replies to a top-level discussion comment (GitHub Discussions only support one level of nesting).\n- 'update' - updates an existing discussion comment.\n- 'delete' - deletes a discussion comment.\n- 'mark_answer' - marks a discussion comment as the answer (Q\u0026A only).\n- 'unmark_answer' - unmarks a discussion comment as the answer (Q\u0026A only).\n", + "enum": [ + "add", + "reply", + "update", + "delete", + "mark_answer", + "unmark_answer" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner (required for 'add' and 'reply' methods)", + "type": "string" + }, + "repo": { + "description": "Repository name (required for 'add' and 'reply' methods)", + "type": "string" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "name": "discussion_comment_write" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_commit.snap b/pkg/github/__toolsnaps__/get_commit.snap index 9e2346b59d..122e6210b3 100644 --- a/pkg/github/__toolsnaps__/get_commit.snap +++ b/pkg/github/__toolsnaps__/get_commit.snap @@ -6,10 +6,15 @@ "description": "Get details for a commit from a GitHub repository", "inputSchema": { "properties": { - "include_diff": { - "default": true, - "description": "Whether to include file diffs and stats in the response. Default is true.", - "type": "boolean" + "detail": { + "default": "stats", + "description": "Level of detail to include for changed files. \"none\" omits stats and files entirely. \"stats\" (default) includes per-file metadata: filename, status, and lines-of-code counts (additions, deletions, changes), with no patch content. \"full_patch\" additionally includes the unified diff content for each file and can be very large.", + "enum": [ + "none", + "stats", + "full_patch" + ], + "type": "string" }, "owner": { "description": "Repository owner", diff --git a/pkg/github/__toolsnaps__/get_discussion_comments.snap b/pkg/github/__toolsnaps__/get_discussion_comments.snap index f9e6095650..422fc40bf7 100644 --- a/pkg/github/__toolsnaps__/get_discussion_comments.snap +++ b/pkg/github/__toolsnaps__/get_discussion_comments.snap @@ -14,6 +14,10 @@ "description": "Discussion Number", "type": "number" }, + "includeReplies": { + "description": "When true, each top-level comment will include its replies nested within it (up to 100 replies per comment, which is the GitHub API maximum). Defaults to false.", + "type": "boolean" + }, "owner": { "description": "Repository owner", "type": "string" diff --git a/pkg/github/__toolsnaps__/get_label.snap b/pkg/github/__toolsnaps__/get_label.snap index 854f048c26..379ca7d8df 100644 --- a/pkg/github/__toolsnaps__/get_label.snap +++ b/pkg/github/__toolsnaps__/get_label.snap @@ -1,7 +1,7 @@ { "annotations": { "readOnlyHint": true, - "title": "Get a specific label from a repository." + "title": "Get a specific label from a repository" }, "description": "Get a specific label from a repository.", "inputSchema": { diff --git a/pkg/github/__toolsnaps__/get_me.snap b/pkg/github/__toolsnaps__/get_me.snap index b451b49de6..6f287df092 100644 --- a/pkg/github/__toolsnaps__/get_me.snap +++ b/pkg/github/__toolsnaps__/get_me.snap @@ -1,7 +1,11 @@ { "_meta": { "ui": { - "resourceUri": "ui://github-mcp-server/get-me" + "resourceUri": "ui://github-mcp-server/get-me", + "visibility": [ + "model", + "app" + ] } }, "annotations": { diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index 24cff5df97..a125864f04 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -9,7 +9,7 @@ } }, "annotations": { - "title": "Create or update issue." + "title": "Create or update issue" }, "description": "Create a new or update an existing issue in a GitHub repository.", "inputSchema": { diff --git a/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap b/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap new file mode 100644 index 0000000000..6fb00d2490 --- /dev/null +++ b/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap @@ -0,0 +1,133 @@ +{ + "_meta": { + "ui": { + "resourceUri": "ui://github-mcp-server/issue-write", + "visibility": [ + "model", + "app" + ] + } + }, + "annotations": { + "title": "Create or update issue" + }, + "description": "Create a new or update an existing issue in a GitHub repository.", + "inputSchema": { + "properties": { + "assignees": { + "description": "Usernames to assign to this issue", + "items": { + "type": "string" + }, + "type": "array" + }, + "body": { + "description": "Issue body content", + "type": "string" + }, + "duplicate_of": { + "description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", + "type": "number" + }, + "issue_fields": { + "description": "Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'.", + "items": { + "additionalProperties": false, + "properties": { + "delete": { + "description": "Set to true to clear this field's current value on the issue. Cannot be combined with 'value' or 'field_option_name'.", + "enum": [ + true + ], + "type": "boolean" + }, + "field_name": { + "description": "Issue field name (case-insensitive). Must match a field returned by list_issue_fields for this repository or its organization.", + "type": "string" + }, + "field_option_name": { + "description": "Option name for single-select fields. Validated against the field's options before the API call. Cannot be combined with 'value' or 'delete'.", + "type": "string" + }, + "value": { + "description": "Value to set. Use for text, number, and date fields (date as YYYY-MM-DD). For single-select fields, prefer 'field_option_name' so the option is validated before the API call. Cannot be combined with 'field_option_name' or 'delete'.", + "type": [ + "string", + "number", + "boolean" + ] + } + }, + "required": [ + "field_name" + ], + "type": "object" + }, + "type": "array" + }, + "issue_number": { + "description": "Issue number to update", + "type": "number" + }, + "labels": { + "description": "Labels to apply to this issue", + "items": { + "type": "string" + }, + "type": "array" + }, + "method": { + "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": { + "description": "Milestone number", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "state": { + "description": "New state", + "enum": [ + "open", + "closed" + ], + "type": "string" + }, + "state_reason": { + "description": "Reason for the state change. Ignored unless state is changed.", + "enum": [ + "completed", + "not_planned", + "duplicate" + ], + "type": "string" + }, + "title": { + "description": "Issue title", + "type": "string" + }, + "type": { + "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 f0aca8cc99..de4b98bef7 100644 --- a/pkg/github/__toolsnaps__/label_write.snap +++ b/pkg/github/__toolsnaps__/label_write.snap @@ -1,6 +1,6 @@ { "annotations": { - "title": "Write operations on repository labels." + "title": "Write operations on repository labels" }, "description": "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool.", "inputSchema": { diff --git a/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap index 5b7d79ef4d..9eddf045d8 100644 --- a/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap +++ b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap @@ -10,6 +10,17 @@ "description": "The owner of the repository.", "type": "string" }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, "ref": { "description": "The Git reference for the results you want to list.", "type": "string" diff --git a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap index 83f7259878..55d5437796 100644 --- a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap +++ b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap @@ -10,6 +10,17 @@ "description": "The owner of the repository.", "type": "string" }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, "repo": { "description": "The name of the repository.", "type": "string" diff --git a/pkg/github/__toolsnaps__/list_issue_fields.snap b/pkg/github/__toolsnaps__/list_issue_fields.snap new file mode 100644 index 0000000000..0eec8bc9e1 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_issue_fields.snap @@ -0,0 +1,24 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List issue fields" + }, + "description": "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly.", + "inputSchema": { + "properties": { + "owner": { + "description": "The account owner of the repository or organization. The name is not case sensitive.", + "type": "string" + }, + "repo": { + "description": "The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly.", + "type": "string" + } + }, + "required": [ + "owner" + ], + "type": "object" + }, + "name": "list_issue_fields" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap b/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap new file mode 100644 index 0000000000..b1d1c7a21d --- /dev/null +++ b/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap @@ -0,0 +1,92 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List issues" + }, + "description": "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.", + "inputSchema": { + "properties": { + "after": { + "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + "type": "string" + }, + "direction": { + "description": "Order direction. If provided, the 'orderBy' also needs to be provided.", + "enum": [ + "ASC", + "DESC" + ], + "type": "string" + }, + "field_filters": { + "description": "Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date).", + "items": { + "properties": { + "field_name": { + "description": "Name of the custom field (e.g. \"Priority\"). Case-insensitive.", + "type": "string" + }, + "value": { + "description": "Value to filter on. For single-select fields, the option name (e.g. \"P1\"). For dates, YYYY-MM-DD. For numbers, the numeric value as a string. For text, the text value.", + "type": "string" + } + }, + "required": [ + "field_name", + "value" + ], + "type": "object" + }, + "type": "array" + }, + "labels": { + "description": "Filter by labels", + "items": { + "type": "string" + }, + "type": "array" + }, + "orderBy": { + "description": "Order issues by field. If provided, the 'direction' also needs to be provided.", + "enum": [ + "CREATED_AT", + "UPDATED_AT", + "COMMENTS" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "since": { + "description": "Filter by date (ISO 8601 timestamp)", + "type": "string" + }, + "state": { + "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 debc2d44e9..9bf8a9f3e0 100644 --- a/pkg/github/__toolsnaps__/list_label.snap +++ b/pkg/github/__toolsnaps__/list_label.snap @@ -1,7 +1,7 @@ { "annotations": { "readOnlyHint": true, - "title": "List labels from a repository." + "title": "List labels from a repository" }, "description": "List labels from a repository", "inputSchema": { diff --git a/pkg/github/__toolsnaps__/list_repository_collaborators.snap b/pkg/github/__toolsnaps__/list_repository_collaborators.snap new file mode 100644 index 0000000000..629e4bdf1c --- /dev/null +++ b/pkg/github/__toolsnaps__/list_repository_collaborators.snap @@ -0,0 +1,45 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List repository collaborators" + }, + "description": "List collaborators of a GitHub repository. Results are paginated; the response includes `nextPage`, `prevPage`, `firstPage`, and `lastPage` fields. To get the next page, use the `nextPage` value as the `page` parameter.", + "inputSchema": { + "properties": { + "affiliation": { + "description": "Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all'", + "enum": [ + "outside", + "direct", + "all" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "page": { + "description": "Page number for pagination (default 1, min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (default 30, min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "list_repository_collaborators" +} \ 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 f2f7cb1259..5c6a21a0ab 100644 --- a/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap +++ b/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap @@ -10,6 +10,17 @@ "description": "The owner of the repository.", "type": "string" }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, "repo": { "description": "The name of the repository.", "type": "string" diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index f6d3197b84..6c9d349f63 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -1,15 +1,19 @@ { "annotations": { "destructiveHint": true, - "title": "Modify GitHub Project items" + "title": "Manage GitHub Projects" }, - "description": "Add, update, or delete project items, or create status updates in a GitHub Project.", + "description": "Create and manage GitHub Projects: create projects, add/update/delete items, create status updates, and add iteration fields.", "inputSchema": { "properties": { "body": { "description": "The body of the status update (markdown). Used for 'create_project_status_update' method.", "type": "string" }, + "field_name": { + "description": "The name of the iteration field (e.g. 'Sprint'). Required for 'create_iteration_field' method.", + "type": "string" + }, "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" @@ -34,13 +38,46 @@ ], "type": "string" }, + "iteration_duration": { + "description": "Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method.", + "type": "number" + }, + "iterations": { + "description": "Custom iterations for 'create_iteration_field' method. Only set this when you need iterations with varying durations, breaks between them, or specific titles. Otherwise omit it: GitHub auto-creates three iterations of 'iteration_duration' days starting on 'start_date', which is the right choice for most cases.", + "items": { + "additionalProperties": false, + "properties": { + "duration": { + "description": "Duration in days", + "type": "number" + }, + "start_date": { + "description": "Start date in YYYY-MM-DD format", + "type": "string" + }, + "title": { + "description": "Iteration title (e.g. 'Sprint 1')", + "type": "string" + } + }, + "required": [ + "title", + "start_date", + "duration" + ], + "type": "object" + }, + "type": "array" + }, "method": { "description": "The method to execute", "enum": [ "add_project_item", "update_project_item", "delete_project_item", - "create_project_status_update" + "create_project_status_update", + "create_project", + "create_iteration_field" ], "type": "string" }, @@ -49,7 +86,7 @@ "type": "string" }, "owner_type": { - "description": "Owner type (user or org). If not provided, will be automatically detected.", + "description": "Owner type (user or org). Required for 'create_project' method. If not provided for other methods, will be automatically detected.", "enum": [ "user", "org" @@ -57,7 +94,7 @@ "type": "string" }, "project_number": { - "description": "The project's number.", + "description": "The project's number. Required for all methods except 'create_project'.", "type": "number" }, "pull_request_number": { @@ -65,7 +102,7 @@ "type": "number" }, "start_date": { - "description": "The start date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", + "description": "Start date in YYYY-MM-DD format. Used for 'create_project_status_update' and 'create_iteration_field' methods.", "type": "string" }, "status": { @@ -83,6 +120,10 @@ "description": "The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", "type": "string" }, + "title": { + "description": "The project title. Required for 'create_project' method.", + "type": "string" + }, "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" @@ -90,8 +131,7 @@ }, "required": [ "method", - "owner", - "project_number" + "owner" ], "type": "object" }, diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap index 9bb14cc076..d70f77e1e0 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -6,8 +6,12 @@ "description": "Get information on a specific pull request in GitHub repository.", "inputSchema": { "properties": { + "after": { + "description": "Cursor for pagination, used only by the get_review_comments method. Pass the endCursor from the previous page's PageInfo to fetch the next page.", + "type": "string" + }, "method": { - "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 combined commit status of a head commit in a pull request.\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 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", + "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 combined commit status of a head commit in a pull request.\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. Use with pagination parameters to control the number of results returned.\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 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", "enum": [ "get", "get_diff", diff --git a/pkg/github/__toolsnaps__/pull_request_review_write.snap b/pkg/github/__toolsnaps__/pull_request_review_write.snap index 7e314005f5..d4a7c30d32 100644 --- a/pkg/github/__toolsnaps__/pull_request_review_write.snap +++ b/pkg/github/__toolsnaps__/pull_request_review_write.snap @@ -1,6 +1,6 @@ { "annotations": { - "title": "Write operations (create, submit, delete) on pull request reviews." + "title": "Write operations (create, submit, delete) on pull request reviews" }, "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- resolve_thread: Resolve a review thread. Requires only \"threadId\" parameter with the thread's node ID (e.g., PRRT_kwDOxxx). The owner, repo, and pullNumber parameters are not used for this method. Resolving an already-resolved thread is a no-op.\n- unresolve_thread: Unresolve a previously resolved review thread. Requires only \"threadId\" parameter. The owner, repo, and pullNumber parameters are not used for this method. Unresolving an already-unresolved thread is a no-op.\n", "inputSchema": { diff --git a/pkg/github/__toolsnaps__/push_files.snap b/pkg/github/__toolsnaps__/push_files.snap index c36c236f98..df6c4d1e79 100644 --- a/pkg/github/__toolsnaps__/push_files.snap +++ b/pkg/github/__toolsnaps__/push_files.snap @@ -12,6 +12,7 @@ "files": { "description": "Array of file objects to push, each object with path (string) and content (string)", "items": { + "additionalProperties": false, "properties": { "content": { "description": "file content", diff --git a/pkg/github/__toolsnaps__/remove_sub_issue.snap b/pkg/github/__toolsnaps__/remove_sub_issue.snap new file mode 100644 index 0000000000..31fdcbb3e2 --- /dev/null +++ b/pkg/github/__toolsnaps__/remove_sub_issue.snap @@ -0,0 +1,37 @@ +{ + "annotations": { + "destructiveHint": true, + "openWorldHint": true, + "title": "Remove Sub-Issue" + }, + "description": "Remove a sub-issue from a parent issue.", + "inputSchema": { + "properties": { + "issue_number": { + "description": "The parent issue number", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "sub_issue_id": { + "description": "The ID of the sub-issue to remove. ID is not the same as issue number", + "type": "number" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "sub_issue_id" + ], + "type": "object" + }, + "name": "remove_sub_issue" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap b/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap new file mode 100644 index 0000000000..d4e1ea4be4 --- /dev/null +++ b/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap @@ -0,0 +1,45 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Reprioritize Sub-Issue" + }, + "description": "Reprioritize (reorder) a sub-issue relative to other sub-issues.", + "inputSchema": { + "properties": { + "after_id": { + "description": "The ID of the sub-issue to place this after (either after_id OR before_id should be specified)", + "type": "number" + }, + "before_id": { + "description": "The ID of the sub-issue to place this before (either after_id OR before_id should be specified)", + "type": "number" + }, + "issue_number": { + "description": "The parent issue number", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "sub_issue_id": { + "description": "The ID of the sub-issue to reorder. ID is not the same as issue number", + "type": "number" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "sub_issue_id" + ], + "type": "object" + }, + "name": "reprioritize_sub_issue" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap b/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap new file mode 100644 index 0000000000..20f1ab62b6 --- /dev/null +++ b/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap @@ -0,0 +1,40 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Request Pull Request Reviewers" + }, + "description": "Request reviewers for a pull request.", + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "pullNumber": { + "description": "The pull request number", + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "reviewers": { + "description": "GitHub usernames or ORG/team-slug team reviewers to request reviews from", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "owner", + "repo", + "pullNumber", + "reviewers" + ], + "type": "object" + }, + "name": "request_pull_request_reviewers" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/resolve_review_thread.snap b/pkg/github/__toolsnaps__/resolve_review_thread.snap new file mode 100644 index 0000000000..afcd407841 --- /dev/null +++ b/pkg/github/__toolsnaps__/resolve_review_thread.snap @@ -0,0 +1,21 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Resolve Review Thread" + }, + "description": "Resolve a review thread on a pull request. Resolving an already-resolved thread is a no-op.", + "inputSchema": { + "properties": { + "threadID": { + "description": "The node ID of the review thread to resolve (e.g., PRRT_kwDOxxx)", + "type": "string" + } + }, + "required": [ + "threadID" + ], + "type": "object" + }, + "name": "resolve_review_thread" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_code.snap b/pkg/github/__toolsnaps__/search_code.snap index 8b5510aa61..79cbbf04e9 100644 --- a/pkg/github/__toolsnaps__/search_code.snap +++ b/pkg/github/__toolsnaps__/search_code.snap @@ -26,7 +26,7 @@ "type": "number" }, "query": { - "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 (GitHub code search REST). Implicit AND between terms; supports `OR`, `NOT`, and `\"quoted phrase\"` for exact match. Qualifiers: `repo:owner/repo`, `org:`, `user:`, `language:`, `path:dir` (prefix match), `filename:exact.ext`, `extension:`, `in:file`, `in:path`, `size:`, `is:archived`, `is:fork`. Max 256 chars. Examples: `WithContext language:go org:github`; `\"package main\" repo:o/r`; `func extension:go path:cmd repo:o/r`; `NOT TODO language:go repo:o/r`.", "type": "string" }, "sort": { diff --git a/pkg/github/__toolsnaps__/search_commits.snap b/pkg/github/__toolsnaps__/search_commits.snap new file mode 100644 index 0000000000..394bce9a1c --- /dev/null +++ b/pkg/github/__toolsnaps__/search_commits.snap @@ -0,0 +1,47 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Search commits" + }, + "description": "Search for commits across GitHub repositories using GitHub's commit search syntax. Useful for finding specific changes, authors, or messages across one or many repositories. Searches the default branch only.", + "inputSchema": { + "properties": { + "order": { + "description": "Sort order", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "query": { + "description": "Commit search query (GitHub commit search REST). Searches commit messages on the default branch only. Scope the search with `repo:owner/repo`, `org:`, or `user:` (queries without a scope qualifier match across all of GitHub and are usually not what you want). Other qualifiers: `author:`, `committer:`, `author-name:`, `committer-name:`, `author-email:`, `committer-email:`, `author-date:`, `committer-date:` (supports `\u003e`, `\u003c`, `\u003e=`, `\u003c=`, and `YYYY-MM-DD..YYYY-MM-DD` ranges), `merge:true|false`, `hash:`, `tree:`, `parent:`, `is:public`. Examples: `repo:owner/repo fix panic`; `org:github author:defunkt committer-date:\u003e=2024-01-01`; `\"refactor cache\" repo:o/r`; `hash:abc1234 repo:o/r`.", + "type": "string" + }, + "sort": { + "description": "Sort by author or committer date (defaults to best match)", + "enum": [ + "author-date", + "committer-date" + ], + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "search_commits" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/set_issue_fields.snap b/pkg/github/__toolsnaps__/set_issue_fields.snap new file mode 100644 index 0000000000..e46febeeda --- /dev/null +++ b/pkg/github/__toolsnaps__/set_issue_fields.snap @@ -0,0 +1,88 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Set Issue Fields" + }, + "description": "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice.", + "inputSchema": { + "properties": { + "fields": { + "description": "Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, or 'single_select_option_id' (the GraphQL node ID of the option) for single select fields. Set 'delete' to true to remove a field value.", + "items": { + "properties": { + "confidence": { + "description": "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "date_value": { + "description": "The value to set for a date field (ISO 8601 date string)", + "type": "string" + }, + "delete": { + "description": "Set to true to delete this field value", + "type": "boolean" + }, + "field_id": { + "description": "The GraphQL node ID of the issue field", + "type": "string" + }, + "is_suggestion": { + "description": "If true, this field value is sent to the API as a suggestion (suggest:true) rather than an applied value. Whether the value is applied or recorded as a proposal is determined by the API.", + "type": "boolean" + }, + "number_value": { + "description": "The value to set for a number field", + "type": "number" + }, + "rationale": { + "description": "One concise sentence explaining what specifically about the issue led you to choose this field value. State the concrete signal (e.g. 'Reports a crash when saving' → high priority).", + "maxLength": 280, + "type": "string" + }, + "single_select_option_id": { + "description": "The GraphQL node ID of the option to set for a single select field", + "type": "string" + }, + "text_value": { + "description": "The value to set for a text field", + "type": "string" + } + }, + "required": [ + "field_id" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "issue_number": { + "description": "The issue number to update", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "fields" + ], + "type": "object" + }, + "name": "set_issue_fields" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap b/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap new file mode 100644 index 0000000000..81223e2a9d --- /dev/null +++ b/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap @@ -0,0 +1,46 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Submit Pending Pull Request Review" + }, + "description": "Submit a pending pull request review.", + "inputSchema": { + "properties": { + "body": { + "description": "The review body text (optional)", + "type": "string" + }, + "event": { + "description": "The review action to perform", + "enum": [ + "APPROVE", + "REQUEST_CHANGES", + "COMMENT" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "pullNumber": { + "description": "The pull request number", + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "pullNumber", + "event" + ], + "type": "object" + }, + "name": "submit_pending_pull_request_review" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unresolve_review_thread.snap b/pkg/github/__toolsnaps__/unresolve_review_thread.snap new file mode 100644 index 0000000000..d58ba31a6f --- /dev/null +++ b/pkg/github/__toolsnaps__/unresolve_review_thread.snap @@ -0,0 +1,21 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Unresolve Review Thread" + }, + "description": "Unresolve a previously resolved review thread on a pull request. Unresolving an already-unresolved thread is a no-op.", + "inputSchema": { + "properties": { + "threadID": { + "description": "The node ID of the review thread to unresolve (e.g., PRRT_kwDOxxx)", + "type": "string" + } + }, + "required": [ + "threadID" + ], + "type": "object" + }, + "name": "unresolve_review_thread" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_assignees.snap b/pkg/github/__toolsnaps__/update_issue_assignees.snap new file mode 100644 index 0000000000..9c7261c9aa --- /dev/null +++ b/pkg/github/__toolsnaps__/update_issue_assignees.snap @@ -0,0 +1,40 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Update Issue Assignees" + }, + "description": "Update the assignees of an existing issue. This replaces the current assignees with the provided list.", + "inputSchema": { + "properties": { + "assignees": { + "description": "GitHub usernames to assign to this issue", + "items": { + "type": "string" + }, + "type": "array" + }, + "issue_number": { + "description": "The issue number to update", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "assignees" + ], + "type": "object" + }, + "name": "update_issue_assignees" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_body.snap b/pkg/github/__toolsnaps__/update_issue_body.snap new file mode 100644 index 0000000000..c54d69172a --- /dev/null +++ b/pkg/github/__toolsnaps__/update_issue_body.snap @@ -0,0 +1,37 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Update Issue Body" + }, + "description": "Update the body content of an existing issue.", + "inputSchema": { + "properties": { + "body": { + "description": "The new body content for the issue", + "type": "string" + }, + "issue_number": { + "description": "The issue number to update", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "body" + ], + "type": "object" + }, + "name": "update_issue_body" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_labels.snap b/pkg/github/__toolsnaps__/update_issue_labels.snap new file mode 100644 index 0000000000..21f7fea6b6 --- /dev/null +++ b/pkg/github/__toolsnaps__/update_issue_labels.snap @@ -0,0 +1,75 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Update Issue Labels" + }, + "description": "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice.", + "inputSchema": { + "properties": { + "issue_number": { + "description": "The issue number to update", + "minimum": 1, + "type": "number" + }, + "labels": { + "description": "Labels to apply to this issue.", + "items": { + "oneOf": [ + { + "description": "Label name", + "type": "string" + }, + { + "properties": { + "confidence": { + "description": "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "is_suggestion": { + "description": "If true, this label is sent to the API as a suggestion (suggest:true) rather than an applied label. Whether the label is applied or recorded as a proposal is determined by the API.", + "type": "boolean" + }, + "name": { + "description": "Label name", + "type": "string" + }, + "rationale": { + "description": "One concise sentence explaining what specifically about the issue led you to choose this label. State the concrete signal (e.g. 'Reports a crash when saving' → bug).", + "maxLength": 280, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ] + }, + "type": "array" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "labels" + ], + "type": "object" + }, + "name": "update_issue_labels" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_milestone.snap b/pkg/github/__toolsnaps__/update_issue_milestone.snap new file mode 100644 index 0000000000..9188779f0a --- /dev/null +++ b/pkg/github/__toolsnaps__/update_issue_milestone.snap @@ -0,0 +1,38 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Update Issue Milestone" + }, + "description": "Update the milestone of an existing issue.", + "inputSchema": { + "properties": { + "issue_number": { + "description": "The issue number to update", + "minimum": 1, + "type": "number" + }, + "milestone": { + "description": "The milestone number to set on the issue", + "minimum": 1, + "type": "integer" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "milestone" + ], + "type": "object" + }, + "name": "update_issue_milestone" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_state.snap b/pkg/github/__toolsnaps__/update_issue_state.snap new file mode 100644 index 0000000000..b14d737b7d --- /dev/null +++ b/pkg/github/__toolsnaps__/update_issue_state.snap @@ -0,0 +1,50 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Update Issue State" + }, + "description": "Update the state of an existing issue (open or closed), with an optional state reason.", + "inputSchema": { + "properties": { + "issue_number": { + "description": "The issue number to update", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "state": { + "description": "The new state for the issue", + "enum": [ + "open", + "closed" + ], + "type": "string" + }, + "state_reason": { + "description": "The reason for the state change (only for closed state)", + "enum": [ + "completed", + "not_planned", + "duplicate" + ], + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "state" + ], + "type": "object" + }, + "name": "update_issue_state" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_title.snap b/pkg/github/__toolsnaps__/update_issue_title.snap new file mode 100644 index 0000000000..825fab0655 --- /dev/null +++ b/pkg/github/__toolsnaps__/update_issue_title.snap @@ -0,0 +1,37 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Update Issue Title" + }, + "description": "Update the title of an existing issue.", + "inputSchema": { + "properties": { + "issue_number": { + "description": "The issue number to update", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "title": { + "description": "The new title for the issue", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "title" + ], + "type": "object" + }, + "name": "update_issue_title" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_type.snap b/pkg/github/__toolsnaps__/update_issue_type.snap new file mode 100644 index 0000000000..2f39b2d3b8 --- /dev/null +++ b/pkg/github/__toolsnaps__/update_issue_type.snap @@ -0,0 +1,55 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Update Issue Type" + }, + "description": "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice.", + "inputSchema": { + "properties": { + "confidence": { + "description": "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "is_suggestion": { + "description": "If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. Whether the type is applied or recorded as a proposal is determined by the API.", + "type": "boolean" + }, + "issue_number": { + "description": "The issue number to update", + "minimum": 1, + "type": "number" + }, + "issue_type": { + "description": "The issue type to set", + "type": "string" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "rationale": { + "description": "One concise sentence explaining what specifically about the issue led you to choose this type. State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature).", + "maxLength": 280, + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "issue_type" + ], + "type": "object" + }, + "name": "update_issue_type" +} \ 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 ef330188ff..3d87fe75fe 100644 --- a/pkg/github/__toolsnaps__/update_pull_request.snap +++ b/pkg/github/__toolsnaps__/update_pull_request.snap @@ -34,7 +34,7 @@ "type": "string" }, "reviewers": { - "description": "GitHub usernames to request reviews from", + "description": "GitHub usernames or ORG/team-slug team reviewers to request reviews from", "items": { "type": "string" }, diff --git a/pkg/github/__toolsnaps__/update_pull_request_body.snap b/pkg/github/__toolsnaps__/update_pull_request_body.snap new file mode 100644 index 0000000000..1e6040bd4d --- /dev/null +++ b/pkg/github/__toolsnaps__/update_pull_request_body.snap @@ -0,0 +1,37 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Update Pull Request Body" + }, + "description": "Update the body description of an existing pull request.", + "inputSchema": { + "properties": { + "body": { + "description": "The new body content for the pull request", + "type": "string" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "pullNumber": { + "description": "The pull request number", + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "pullNumber", + "body" + ], + "type": "object" + }, + "name": "update_pull_request_body" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_pull_request_draft_state.snap b/pkg/github/__toolsnaps__/update_pull_request_draft_state.snap new file mode 100644 index 0000000000..2a397951ab --- /dev/null +++ b/pkg/github/__toolsnaps__/update_pull_request_draft_state.snap @@ -0,0 +1,37 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Update Pull Request Draft State" + }, + "description": "Mark a pull request as draft or ready for review.", + "inputSchema": { + "properties": { + "draft": { + "description": "Set to true to convert to draft, false to mark as ready for review", + "type": "boolean" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "pullNumber": { + "description": "The pull request number", + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "pullNumber", + "draft" + ], + "type": "object" + }, + "name": "update_pull_request_draft_state" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_pull_request_state.snap b/pkg/github/__toolsnaps__/update_pull_request_state.snap new file mode 100644 index 0000000000..9cbdb81124 --- /dev/null +++ b/pkg/github/__toolsnaps__/update_pull_request_state.snap @@ -0,0 +1,41 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Update Pull Request State" + }, + "description": "Update the state of an existing pull request (open or closed).", + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "pullNumber": { + "description": "The pull request number", + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "state": { + "description": "The new state for the pull request", + "enum": [ + "open", + "closed" + ], + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "pullNumber", + "state" + ], + "type": "object" + }, + "name": "update_pull_request_state" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_pull_request_title.snap b/pkg/github/__toolsnaps__/update_pull_request_title.snap new file mode 100644 index 0000000000..e6398ed40a --- /dev/null +++ b/pkg/github/__toolsnaps__/update_pull_request_title.snap @@ -0,0 +1,37 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Update Pull Request Title" + }, + "description": "Update the title of an existing pull request.", + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "pullNumber": { + "description": "The pull request number", + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "title": { + "description": "The new title for the pull request", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "pullNumber", + "title" + ], + "type": "object" + }, + "name": "update_pull_request_title" +} \ No newline at end of file diff --git a/pkg/github/actions.go b/pkg/github/actions.go index c3b5bb8c71..a7ce039d83 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -16,7 +16,7 @@ import ( "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/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -544,6 +544,7 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo "inputs": { Type: "object", Description: "Inputs the workflow accepts. Only used for 'run_workflow' method.", + Properties: map[string]*jsonschema.Schema{}, }, "run_id": { Type: "number", @@ -574,11 +575,9 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo runID, _ := OptionalIntParam(args, "run_id") // Get optional inputs parameter - var inputs map[string]any - if requestInputs, ok := args["inputs"]; ok { - if inputsMap, ok := requestInputs.(map[string]any); ok { - inputs = inputsMap - } + inputs, err := OptionalParam[map[string]any](args, "inputs") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil } // Validate required parameters based on action type @@ -990,10 +989,10 @@ func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workfl var workflowType string if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { - resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) + _, resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) workflowType = "workflow_id" } else { - resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) + _, resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) workflowType = "workflow_file" } diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index fe960ed924..371bbbe9dc 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -86,7 +86,7 @@ func Test_ActionsList_ListWorkflows(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -136,7 +136,7 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -185,7 +185,7 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -241,7 +241,7 @@ func Test_ActionsGet_GetWorkflow(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -284,7 +284,7 @@ func Test_ActionsGet_GetWorkflowRun(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -377,11 +377,42 @@ func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) { expectError: true, expectedErrMsg: "ref is required for run_workflow action", }, + { + name: "successful workflow run with inputs", + 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", + "repo": "repo", + "workflow_id": "12345", + "ref": "main", + "inputs": map[string]any{"FIELD1": "value1", "FIELD2": "value2"}, + }, + expectError: false, + }, + { + name: "invalid inputs type returns error", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "run_workflow", + "owner": "owner", + "repo": "repo", + "workflow_id": "12345", + "ref": "main", + "inputs": "not a map", + }, + expectError: true, + expectedErrMsg: "parameter inputs is not of type map[string]interface {}, is string", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -418,7 +449,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -449,7 +480,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -473,7 +504,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { t.Run("missing run_id for non-run_workflow methods", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -525,7 +556,7 @@ func Test_ActionsGetJobLogs_SingleJob(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, ContentWindowSize: 5000, @@ -587,7 +618,7 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, ContentWindowSize: 5000, @@ -637,7 +668,7 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, ContentWindowSize: 5000, diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index 34249b2129..44307513bb 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -11,7 +11,7 @@ import ( "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/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -94,6 +94,41 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server } func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "state": { + Type: "string", + Description: "Filter code scanning alerts by state. Defaults to open", + Enum: []any{"open", "closed", "dismissed", "fixed"}, + Default: json.RawMessage(`"open"`), + }, + "ref": { + Type: "string", + Description: "The Git reference for the results you want to list.", + }, + "severity": { + Type: "string", + Description: "Filter code scanning alerts by severity", + Enum: []any{"critical", "high", "medium", "low", "warning", "note", "error"}, + }, + "tool_name": { + Type: "string", + Description: "The name of the tool used for code scanning.", + }, + }, + Required: []string{"owner", "repo"}, + } + WithPagination(schema) + return NewTool( ToolsetMetadataCodeSecurity, mcp.Tool{ @@ -103,39 +138,7 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv Title: t("TOOL_LIST_CODE_SCANNING_ALERTS_USER_TITLE", "List code scanning alerts"), ReadOnlyHint: true, }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "The owner of the repository.", - }, - "repo": { - Type: "string", - Description: "The name of the repository.", - }, - "state": { - Type: "string", - Description: "Filter code scanning alerts by state. Defaults to open", - Enum: []any{"open", "closed", "dismissed", "fixed"}, - Default: json.RawMessage(`"open"`), - }, - "ref": { - Type: "string", - Description: "The Git reference for the results you want to list.", - }, - "severity": { - Type: "string", - Description: "Filter code scanning alerts by severity", - Enum: []any{"critical", "high", "medium", "low", "warning", "note", "error"}, - }, - "tool_name": { - Type: "string", - Description: "The name of the tool used for code scanning.", - }, - }, - Required: []string{"owner", "repo"}, - }, + InputSchema: schema, }, []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -164,11 +167,25 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv return utils.NewToolResultError(err.Error()), nil, nil } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - alerts, resp, err := client.CodeScanning.ListAlertsForRepo(ctx, owner, repo, &github.AlertListOptions{Ref: ref, State: state, Severity: severity, ToolName: toolName}) + alerts, resp, err := client.CodeScanning.ListAlertsForRepo(ctx, owner, repo, &github.AlertListOptions{ + Ref: ref, + State: state, + Severity: severity, + ToolName: toolName, + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list alerts", diff --git a/pkg/github/code_scanning_test.go b/pkg/github/code_scanning_test.go index 7a3c16fd15..3d0f261d2a 100644 --- a/pkg/github/code_scanning_test.go +++ b/pkg/github/code_scanning_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -80,7 +80,7 @@ func Test_GetCodeScanningAlert(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -137,6 +137,8 @@ func Test_ListCodeScanningAlerts(t *testing.T) { assert.Contains(t, schema.Properties, "state") assert.Contains(t, schema.Properties, "severity") assert.Contains(t, schema.Properties, "tool_name") + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) // Setup mock alerts for success case @@ -171,6 +173,8 @@ func Test_ListCodeScanningAlerts(t *testing.T) { "state": "open", "severity": "high", "tool_name": "codeql", + "page": "1", + "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, mockAlerts), ), @@ -186,6 +190,25 @@ func Test_ListCodeScanningAlerts(t *testing.T) { expectError: false, expectedAlerts: mockAlerts, }, + { + name: "successful alerts listing with custom pagination", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCodeScanningAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "50", + }).andThen( + mockResponse(t, http.StatusOK, mockAlerts), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "page": float64(2), + "perPage": float64(50), + }, + expectError: false, + expectedAlerts: mockAlerts, + }, { name: "alerts listing fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -206,7 +229,7 @@ func Test_ListCodeScanningAlerts(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 902734481a..4008c2f4aa 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -6,6 +6,7 @@ import ( "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -57,6 +58,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { Meta: mcp.Meta{ "ui": map[string]any{ "resourceUri": GetMeUIResourceURI, + "visibility": []string{"model", "app"}, }, }, }, @@ -103,7 +105,14 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { }, } - return MarshalledTextResult(minimalUser), nil, nil + result := MarshalledTextResult(minimalUser) + if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + if result.Meta == nil { + result.Meta = mcp.Meta{} + } + result.Meta["ifc"] = ifc.LabelGetMe() + } + return result, nil, nil }, ) } diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 3925019853..ade54aba17 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -10,7 +10,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -96,9 +96,10 @@ func Test_GetMe(t *testing.T) { t.Run(tc.name, func(t *testing.T) { var deps ToolDependencies if tc.clientErr != "" { - deps = stubDeps{clientFn: stubClientFnErr(tc.clientErr)} + deps = stubDeps{clientFn: stubClientFnErr(tc.clientErr), obsv: stubExporters()} } else { - deps = BaseDeps{Client: github.NewClient(tc.mockedClient)} + obs := stubExporters() + deps = BaseDeps{Client: mustNewGHClient(t, tc.mockedClient), Obsv: obs} } handler := serverTool.Handler(deps) @@ -138,6 +139,70 @@ func Test_GetMe(t *testing.T) { } } +func Test_GetMe_IFC_FeatureFlag(t *testing.T) { + t.Parallel() + + serverTool := GetMe(translations.NullTranslationHelper) + + mockUser := &github.User{ + Login: github.Ptr("testuser"), + HTMLURL: github.Ptr("https://github.com/testuser"), + CreatedAt: &github.Timestamp{Time: time.Now()}, + } + mockedHTTPClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUser: mockResponse(t, http.StatusOK, mockUser), + }) + + depsWithIFCFeature := func(enabled bool) *BaseDeps { + return NewBaseDeps( + mustNewGHClient(t, mockedHTTPClient), nil, nil, nil, + translations.NullTranslationHelper, + FeatureFlags{}, + 0, + func(_ context.Context, flagName string) (bool, error) { + return flagName == FeatureFlagIFCLabels && enabled, nil + }, + stubExporters(), + ) + } + + t.Run("feature disabled omits ifc label from result meta", func(t *testing.T) { + deps := depsWithIFCFeature(false) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{}) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assert.Nil(t, result.Meta, "result meta should be nil when IFC labels are disabled") + }) + + t.Run("feature enabled includes ifc label in result meta", func(t *testing.T) { + deps := depsWithIFCFeature(true) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{}) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta, "result meta should be set when IFC labels are enabled") + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + + var ifcMap map[string]any + err = json.Unmarshal(ifcJSON, &ifcMap) + require.NoError(t, err) + + assert.Equal(t, "trusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) +} + func Test_GetTeams(t *testing.T) { t.Parallel() @@ -268,7 +333,7 @@ func Test_GetTeams(t *testing.T) { name: "successful get teams", makeDeps: func() ToolDependencies { return BaseDeps{ - Client: github.NewClient(httpClientWithUser()), + Client: mustNewGHClient(t, httpClientWithUser()), GQLClient: gqlClientForTestuser(), } }, @@ -293,7 +358,7 @@ func Test_GetTeams(t *testing.T) { name: "no teams found", makeDeps: func() ToolDependencies { return BaseDeps{ - Client: github.NewClient(httpClientWithUser()), + Client: mustNewGHClient(t, httpClientWithUser()), GQLClient: gqlClientNoTeams(), } }, @@ -304,7 +369,7 @@ func Test_GetTeams(t *testing.T) { { name: "getting client fails", makeDeps: func() ToolDependencies { - return stubDeps{clientFn: stubClientFnErr("expected test error")} + return stubDeps{clientFn: stubClientFnErr("expected test error"), obsv: stubExporters()} }, requestArgs: map[string]any{}, expectToolError: true, @@ -314,7 +379,8 @@ func Test_GetTeams(t *testing.T) { name: "get user fails", makeDeps: func() ToolDependencies { return BaseDeps{ - Client: github.NewClient(httpClientUserFails()), + Client: mustNewGHClient(t, httpClientUserFails()), + Obsv: stubExporters(), } }, requestArgs: map[string]any{}, @@ -325,8 +391,9 @@ func Test_GetTeams(t *testing.T) { name: "getting GraphQL client fails", makeDeps: func() ToolDependencies { return stubDeps{ - clientFn: stubClientFnFromHTTP(httpClientWithUser()), + clientFn: stubClientFnFromHTTP(t, httpClientWithUser()), gqlClientFn: stubGQLClientFnErr("GraphQL client error"), + obsv: stubExporters(), } }, requestArgs: map[string]any{}, @@ -469,7 +536,7 @@ func Test_GetTeamMembers(t *testing.T) { }, { name: "getting GraphQL client fails", - deps: stubDeps{gqlClientFn: stubGQLClientFnErr("GraphQL client error")}, + deps: stubDeps{gqlClientFn: stubGQLClientFnErr("GraphQL client error"), obsv: stubExporters()}, requestArgs: map[string]any{ "org": "testorg", "team_slug": "testteam", diff --git a/pkg/github/copilot.go b/pkg/github/copilot.go index d95357e738..017bb98bc9 100644 --- a/pkg/github/copilot.go +++ b/pkg/github/copilot.go @@ -17,7 +17,7 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/copilot_test.go b/pkg/github/copilot_test.go index 0a1d5ef3b6..b86f26f474 100644 --- a/pkg/github/copilot_test.go +++ b/pkg/github/copilot_test.go @@ -10,7 +10,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -932,7 +932,7 @@ func Test_RequestCopilotReview(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := RequestCopilotReview(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, diff --git a/pkg/github/csv_output.go b/pkg/github/csv_output.go new file mode 100644 index 0000000000..6acb8b2fdb --- /dev/null +++ b/pkg/github/csv_output.go @@ -0,0 +1,411 @@ +package github + +import ( + "bytes" + "context" + "encoding/csv" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Ordered by preference when a response wrapper contains multiple arrays. +var primaryCSVRowKeys = []string{ + "items", + "issues", + "discussions", + "categories", + "labels", + "alerts", + "advisories", + "notifications", + "gists", + "repositories", + "commits", + "branches", + "tags", + "releases", + "users", + "teams", + "members", + "projects", + "nodes", +} + +type csvOutputDocument struct { + metadata map[string]string + rows []map[string]string +} + +// withCSVOutput wraps the handler of every default-toolset list_* tool so that, +// at request time, it checks the csv_output feature flag and converts the JSON +// text response to CSV when enabled. The tool's schema, name, and scope are +// unchanged — only the response payload format differs. +func withCSVOutput(tools []inventory.ServerTool) []inventory.ServerTool { + for i := range tools { + if !isCSVOutputTool(tools[i]) { + continue + } + tools[i].HandlerFunc = wrapHandlerWithCSVOutput(tools[i].HandlerFunc) + } + return tools +} + +// isCSVOutputTool reports whether the given tool should have its handler +// wrapped to honor the csv_output feature flag. Wrapping happens at slice +// construction time, before the per-request feature-flag filter chooses which +// variant of a flag-gated tool to register, so flag-gated list_* tools are +// included on equal footing — only the live variant ever runs at request time. +func isCSVOutputTool(tool inventory.ServerTool) bool { + if !tool.Toolset.Default { + return false + } + return strings.HasPrefix(tool.Tool.Name, "list_") +} + +func wrapHandlerWithCSVOutput(next inventory.HandlerFunc) inventory.HandlerFunc { + return func(deps any) mcp.ToolHandler { + handler := next(deps) + csvDeps, _ := deps.(ToolDependencies) + return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + result, err := handler(ctx, req) + if err != nil || result == nil || result.IsError { + return result, err + } + if csvDeps == nil || !csvDeps.IsFeatureEnabled(ctx, FeatureFlagCSVOutput) { + return result, nil + } + return convertJSONTextResultToCSV(result), nil + } + } +} + +func convertJSONTextResultToCSV(result *mcp.CallToolResult) *mcp.CallToolResult { + if len(result.Content) != 1 { + return utils.NewToolResultError("failed to convert response to CSV: expected a single text content response") + } + + text, ok := result.Content[0].(*mcp.TextContent) + if !ok { + return utils.NewToolResultError("failed to convert response to CSV: expected a text content response") + } + + csvText, err := jsonTextToCSV(text.Text) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to convert response to CSV", err) + } + + result.Content = []mcp.Content{&mcp.TextContent{Text: csvText}} + result.StructuredContent = nil + return result +} + +func jsonTextToCSV(text string) (string, error) { + decoder := json.NewDecoder(strings.NewReader(text)) + decoder.UseNumber() + + var value any + if err := decoder.Decode(&value); err != nil { + return "", fmt.Errorf("failed to unmarshal JSON text: %w", err) + } + + doc := csvDocument(value) + if len(doc.metadata) == 0 && len(doc.rows) == 0 { + return "", nil + } + + var buf bytes.Buffer + writeCSVMetadata(&buf, doc.metadata) + if len(doc.rows) == 0 { + return buf.String(), nil + } + + headers := csvHeaders(doc.rows) + if len(headers) == 0 { + return buf.String(), nil + } + + writer := csv.NewWriter(&buf) + if err := writer.Write(headers); err != nil { + return "", fmt.Errorf("failed to write CSV header: %w", err) + } + + for _, row := range doc.rows { + record := make([]string, len(headers)) + for i, header := range headers { + record[i] = row[header] + } + if err := writer.Write(record); err != nil { + return "", fmt.Errorf("failed to write CSV row: %w", err) + } + } + + writer.Flush() + if err := writer.Error(); err != nil { + return "", fmt.Errorf("failed to flush CSV: %w", err) + } + return buf.String(), nil +} + +func csvDocument(value any) csvOutputDocument { + switch v := value.(type) { + case []any: + return csvOutputDocument{rows: csvRowsFromArray(v)} + case map[string]any: + if rows, metadata, ok := primaryRowsFromMap(v); ok { + return csvOutputDocument{ + metadata: newFlattenedCSVRow(metadata), + rows: csvRowsFromArray(rows), + } + } + return csvOutputDocument{rows: []map[string]string{newFlattenedCSVRow(v)}} + default: + return csvOutputDocument{rows: []map[string]string{scalarCSVRow(v)}} + } +} + +func primaryRowsFromMap(value map[string]any) ([]any, map[string]any, bool) { + if rows, path, ok := primaryRowsAtCurrentLevel(value); ok { + return rows, metadataWithoutPath(value, path), true + } + if rows, path, ok := primaryRowsOneLevelDown(value); ok { + return rows, metadataWithoutPath(value, path), true + } + return nil, nil, false +} + +func primaryRowsAtCurrentLevel(value map[string]any) ([]any, []string, bool) { + if key, ok := preferredPrimaryRowKey(value); ok { + rows, _ := value[key].([]any) + return rows, []string{key}, true + } + if key, ok := singleArrayKey(value); ok { + rows, _ := value[key].([]any) + return rows, []string{key}, true + } + return nil, nil, false +} + +func primaryRowsOneLevelDown(value map[string]any) ([]any, []string, bool) { + var matchedRows []any + var matchedPath []string + for key, raw := range value { + child, ok := raw.(map[string]any) + if !ok { + continue + } + rows, path, ok := primaryRowsAtCurrentLevel(child) + if !ok { + continue + } + if matchedPath != nil { + return nil, nil, false + } + matchedRows = rows + matchedPath = append([]string{key}, path...) + } + if matchedPath == nil { + return nil, nil, false + } + return matchedRows, matchedPath, true +} + +func metadataWithoutPath(value map[string]any, path []string) map[string]any { + metadata := make(map[string]any, len(value)) + for key, raw := range value { + if key != path[0] { + metadata[key] = raw + continue + } + + if len(path) == 1 { + continue + } + child, ok := raw.(map[string]any) + if !ok { + continue + } + childMetadata := metadataWithoutPath(child, path[1:]) + if len(childMetadata) > 0 { + metadata[key] = childMetadata + } + } + return metadata +} + +func csvRowsFromArray(values []any) []map[string]string { + if len(values) == 0 { + return nil + } + + rows := make([]map[string]string, 0, len(values)) + for _, value := range values { + var row map[string]string + switch v := value.(type) { + case map[string]any: + row = make(map[string]string) + appendFlattenedCSVFields(row, v, "") + default: + row = scalarCSVRow(v) + } + rows = append(rows, row) + } + return rows +} + +func writeCSVMetadata(buf *bytes.Buffer, metadata map[string]string) { + if len(metadata) == 0 { + return + } + + headers := make([]string, 0, len(metadata)) + for header := range metadata { + headers = append(headers, header) + } + sort.Strings(headers) + + for _, header := range headers { + fmt.Fprintf(buf, "# %s: %s\n", header, normalizeCSVWhitespace(metadata[header])) + } + buf.WriteByte('\n') +} + +func newFlattenedCSVRow(value map[string]any) map[string]string { + row := make(map[string]string) + appendFlattenedCSVFields(row, value, "") + return row +} + +func appendFlattenedCSVFields(row map[string]string, value map[string]any, prefix string) { + if value == nil { + return + } + + for key, raw := range value { + column := csvColumnName(prefix, key) + switch v := raw.(type) { + case map[string]any: + appendFlattenedCSVFields(row, v, column) + case []any: + row[column] = csvArrayValue(v) + default: + row[column] = csvColumnValue(column, v) + } + } +} + +func csvHeaders(rows []map[string]string) []string { + headerSet := make(map[string]struct{}) + for _, row := range rows { + for header := range row { + headerSet[header] = struct{}{} + } + } + + headers := make([]string, 0, len(headerSet)) + for header := range headerSet { + headers = append(headers, header) + } + sort.Strings(headers) + return headers +} + +func csvColumnName(prefix, key string) string { + if prefix == "" { + return key + } + return prefix + "." + key +} + +func preferredPrimaryRowKey(value map[string]any) (string, bool) { + for _, key := range primaryCSVRowKeys { + if _, ok := value[key].([]any); ok { + return key, true + } + } + return "", false +} + +func singleArrayKey(value map[string]any) (string, bool) { + var arrayKey string + for key, raw := range value { + if _, ok := raw.([]any); !ok { + continue + } + if arrayKey != "" { + return "", false + } + arrayKey = key + } + if arrayKey == "" { + return "", false + } + return arrayKey, true +} + +func csvColumnValue(column string, value any) string { + str := scalarCSVValue(value) + if isBodyColumn(column) { + return normalizeCSVWhitespace(str) + } + return str +} + +func csvArrayValue(values []any) string { + if len(values) == 0 { + return "" + } + + // Scalar arrays use semicolons for compactness. This is lossy if an + // element contains a semicolon; use JSON mode when exact reconstruction matters. + parts := make([]string, 0, len(values)) + for _, value := range values { + switch value.(type) { + case map[string]any, []any: + encoded, err := json.Marshal(value) + if err != nil { + parts = append(parts, scalarCSVValue(value)) + } else { + parts = append(parts, string(encoded)) + } + default: + parts = append(parts, scalarCSVValue(value)) + } + } + return strings.Join(parts, ";") +} + +func scalarCSVRow(value any) map[string]string { + return map[string]string{"value": scalarCSVValue(value)} +} + +func scalarCSVValue(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return v + case json.Number: + return v.String() + case bool: + if v { + return "true" + } + return "false" + default: + return fmt.Sprint(v) + } +} + +func isBodyColumn(column string) bool { + return column == "body" || strings.HasSuffix(column, ".body") +} + +func normalizeCSVWhitespace(value string) string { + return strings.Join(strings.Fields(value), " ") +} diff --git a/pkg/github/csv_output_test.go b/pkg/github/csv_output_test.go new file mode 100644 index 0000000000..b9ff2e3edc --- /dev/null +++ b/pkg/github/csv_output_test.go @@ -0,0 +1,433 @@ +package github + +import ( + "context" + "encoding/csv" + "encoding/json" + "strings" + "testing" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCSVOutputAppliedToDefaultListTools(t *testing.T) { + listTool := testCSVOutputTool("list_things", `[{"number":1}]`) + getTool := testCSVOutputTool("get_thing", `{"number":1}`) + + tools := withCSVOutput([]inventory.ServerTool{listTool, getTool}) + require.Len(t, tools, 2) + + // CSV mode does not introduce variants or change tool gating; both tools + // remain visible regardless of feature flag state. + for _, csvOutputEnabled := range []bool{false, true} { + inv := buildCSVOutputInventory(t, tools, csvOutputEnabled) + available := inv.AvailableTools(context.Background()) + require.Len(t, available, 2) + + listing := requireToolByName(t, available, "list_things") + assert.Empty(t, listing.FeatureFlagEnable) + assert.Empty(t, listing.FeatureFlagDisable) + + getting := requireToolByName(t, available, "get_thing") + assert.Empty(t, getting.FeatureFlagEnable) + assert.Empty(t, getting.FeatureFlagDisable) + } +} + +func TestCSVOutputAppliesToFlagGatedListTools(t *testing.T) { + enabledOnly := testCSVOutputTool("list_things", `[{"number":1}]`) + enabledOnly.FeatureFlagEnable = FeatureFlagIssueFields + disabledOnly := testCSVOutputTool("list_legacy_things", `[{"number":2}]`) + disabledOnly.FeatureFlagDisable = []string{FeatureFlagIssueFields} + + tools := withCSVOutput([]inventory.ServerTool{enabledOnly, disabledOnly}) + require.Len(t, tools, 2) + + // Both flag-gated variants get the CSV wrapper; the per-request flag filter + // decides which one actually registers, and the runtime csv_output check + // decides whether the wrapper converts the response. + deps := newCSVOutputTestDeps(true) + for _, tool := range tools { + result, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest()) + require.NoError(t, err) + assert.Contains(t, textResult(t, result), "number\n") + } +} + +func TestCSVOutputOnlyAppliesToDefaultToolsets(t *testing.T) { + nonDefaultListTool := testCSVOutputToolWithToolset("list_discussions", `[{"number":1}]`, ToolsetMetadataDiscussions) + + tools := withCSVOutput([]inventory.ServerTool{nonDefaultListTool}) + require.Len(t, tools, 1) + + // Non-default toolset list tools are not wrapped: even with the flag on, + // the response stays in JSON form. + deps := newCSVOutputTestDeps(true) + result, err := tools[0].Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest()) + require.NoError(t, err) + assert.JSONEq(t, `[{"number":1}]`, textResult(t, result)) +} + +func TestCSVOutputDoesNotExposeFormatParameter(t *testing.T) { + tools := withCSVOutput([]inventory.ServerTool{testCSVOutputTool("list_things", `[{"number":1}]`)}) + require.Len(t, tools, 1) + + schema, ok := tools[0].Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok) + assert.NotContains(t, schema.Properties, "output_format") +} + +func TestCSVOutputConvertsJSONTextToCSVWhenFlagOn(t *testing.T) { + tools := withCSVOutput([]inventory.ServerTool{ + testCSVOutputTool("list_things", `[ + { + "number": 1, + "body": "first line\n\tsecond line", + "labels": ["bug", "help wanted"], + "user": {"login": "octocat"} + } + ]`), + }) + require.Len(t, tools, 1) + + deps := newCSVOutputTestDeps(true) + result, err := tools[0].Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest()) + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError) + + assert.NotContains(t, textResult(t, result), "#") + + records := readCSVResult(t, result) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "first line second line", row["body"]) + assert.Equal(t, "bug;help wanted", row["labels"]) + assert.Equal(t, "1", row["number"]) + assert.Equal(t, "octocat", row["user.login"]) +} + +func TestCSVOutputPreservesOriginalJSONWhenFlagOff(t *testing.T) { + const jsonResponse = `[{"number":1,"user":{"login":"octocat"}}]` + tools := withCSVOutput([]inventory.ServerTool{testCSVOutputTool("list_things", jsonResponse)}) + require.Len(t, tools, 1) + + deps := newCSVOutputTestDeps(false) + result, err := tools[0].Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest()) + require.NoError(t, err) + require.NotNil(t, result) + + require.Len(t, result.Content, 1) + text, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + assert.JSONEq(t, jsonResponse, text.Text) +} + +func TestCSVOutputVariantMovesMetadataToPreamble(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "issues": [ + {"number": 1, "title": "First issue"} + ], + "pageInfo": { + "endCursor": "cursor-1", + "hasNextPage": true + }, + "totalCount": 2 + }`) + require.NoError(t, err) + assert.Contains(t, csvText, "# pageInfo.endCursor: cursor-1\n") + assert.Contains(t, csvText, "# pageInfo.hasNextPage: true\n") + assert.Contains(t, csvText, "# totalCount: 2\n\n") + + records := readCSVText(t, csvText) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "1", row["number"]) + assert.Equal(t, "First issue", row["title"]) + assert.NotContains(t, row, "pageInfo.endCursor") + assert.NotContains(t, row, "totalCount") +} + +func TestJSONTextToCSVFlattensPrimaryRows(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "discussions": [ + { + "number": 5, + "title": "Discussion tools testing", + "category": {"name": "Q&A"}, + "user": {"login": "octocat"} + } + ] + }`) + require.NoError(t, err) + + records := readCSVText(t, csvText) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "Q&A", row["category.name"]) + assert.Equal(t, "5", row["number"]) + assert.Equal(t, "Discussion tools testing", row["title"]) + assert.Equal(t, "octocat", row["user.login"]) +} + +func TestJSONTextToCSVFindsPrimaryRowsOneLevelDeeper(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "issues": { + "nodes": [ + {"number": 5, "title": "Nested issue"} + ], + "pageInfo": {"hasNextPage": false}, + "totalCount": 1 + } + }`) + require.NoError(t, err) + + assert.Contains(t, csvText, "# issues.pageInfo.hasNextPage: false\n") + assert.Contains(t, csvText, "# issues.totalCount: 1\n\n") + + records := readCSVText(t, csvText) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "5", row["number"]) + assert.Equal(t, "Nested issue", row["title"]) +} + +func TestJSONTextToCSVUsesSingleArrayAsPrimaryRows(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "results": [ + {"number": 1, "title": "Single array result"} + ], + "pageInfo": {"hasNextPage": true} + }`) + require.NoError(t, err) + + assert.Contains(t, csvText, "# pageInfo.hasNextPage: true\n\n") + + records := readCSVText(t, csvText) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "1", row["number"]) + assert.Equal(t, "Single array result", row["title"]) + assert.NotContains(t, row, "pageInfo.hasNextPage") +} + +func TestJSONTextToCSVFlattensRootObjectWithoutPrimaryRows(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "name": "summary", + "pageInfo": {"hasNextPage": false}, + "totalCount": 2 + }`) + require.NoError(t, err) + assert.NotContains(t, csvText, "#") + + records := readCSVText(t, csvText) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "summary", row["name"]) + assert.Equal(t, "false", row["pageInfo.hasNextPage"]) + assert.Equal(t, "2", row["totalCount"]) +} + +func TestJSONTextToCSVConvertsScalarToValueRow(t *testing.T) { + csvText, err := jsonTextToCSV(`"plain value"`) + require.NoError(t, err) + + records := readCSVText(t, csvText) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "plain value", row["value"]) +} + +func TestJSONTextToCSVReturnsEmptyForEmptyArray(t *testing.T) { + csvText, err := jsonTextToCSV(`[]`) + require.NoError(t, err) + assert.Empty(t, csvText) +} + +func TestJSONTextToCSVReturnsEmptyForEmptyObject(t *testing.T) { + csvText, err := jsonTextToCSV(`{}`) + require.NoError(t, err) + assert.Empty(t, csvText) +} + +func TestJSONTextToCSVReturnsEmptyForOnlyEmptyNestedObjects(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "repository": { + "owner": {} + } + }`) + require.NoError(t, err) + assert.Empty(t, csvText) +} + +func TestJSONTextToCSVReturnsMetadataOnlyWhenRowsHaveNoColumns(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "items": [ + {} + ], + "totalCount": 1 + }`) + require.NoError(t, err) + assert.Equal(t, "# totalCount: 1\n\n", csvText) +} + +func TestJSONTextToCSVFlattensAmbiguousArraysAsSingleRow(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "foo": ["a", "b"], + "bar": ["c"] + }`) + require.NoError(t, err) + assert.NotContains(t, csvText, "#") + + records := readCSVText(t, csvText) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "c", row["bar"]) + assert.Equal(t, "a;b", row["foo"]) +} + +func TestJSONTextToCSVUsesPreferredArrayWhenMultipleArraysExist(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "items": [ + {"id": 1} + ], + "other": [ + {"id": 2} + ], + "totalCount": 1 + }`) + require.NoError(t, err) + + assert.Contains(t, csvText, "# other: {\"id\":2}\n") + assert.Contains(t, csvText, "# totalCount: 1\n\n") + + records := readCSVText(t, csvText) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "1", row["id"]) +} + +func testCSVOutputTool(name string, response string) inventory.ServerTool { + return testCSVOutputToolWithToolset(name, response, ToolsetMetadataRepos) +} + +func testCSVOutputToolWithToolset(name string, response string, toolset inventory.ToolsetMetadata) inventory.ServerTool { + return inventory.ServerTool{ + Tool: mcp.Tool{ + Name: name, + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{}, + }, + }, + Toolset: toolset, + HandlerFunc: func(_ any) mcp.ToolHandler { + return func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: response}, + }, + }, nil + } + }, + } +} + +func buildCSVOutputInventory(t *testing.T, tools []inventory.ServerTool, _ bool) *inventory.Inventory { + t.Helper() + + inv, err := inventory.NewBuilder(). + SetTools(tools). + Build() + require.NoError(t, err) + return inv +} + +func newCSVOutputTestDeps(csvOutputEnabled bool) ToolDependencies { + return csvOutputTestDeps{stubDeps: stubDeps{obsv: stubExporters()}, csvOn: csvOutputEnabled} +} + +type csvOutputTestDeps struct { + stubDeps + csvOn bool +} + +func (d csvOutputTestDeps) IsFeatureEnabled(_ context.Context, flag string) bool { + return flag == FeatureFlagCSVOutput && d.csvOn +} + +func requireToolByName(t *testing.T, tools []inventory.ServerTool, name string) inventory.ServerTool { + t.Helper() + + for _, tool := range tools { + if tool.Tool.Name == name { + return tool + } + } + require.Failf(t, "tool not found", "tool %q not found", name) + return inventory.ServerTool{} +} + +func testCSVOutputRequest() *mcp.CallToolRequest { + return &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Arguments: json.RawMessage(`{}`), + }, + } +} + +func readCSVResult(t *testing.T, result *mcp.CallToolResult) [][]string { + t.Helper() + + require.Len(t, result.Content, 1) + text, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + + return readCSVText(t, text.Text) +} + +func textResult(t *testing.T, result *mcp.CallToolResult) string { + t.Helper() + + require.Len(t, result.Content, 1) + text, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + return text.Text +} + +func readCSVText(t *testing.T, text string) [][]string { + t.Helper() + + reader := csv.NewReader(strings.NewReader(text)) + reader.Comment = '#' + records, err := reader.ReadAll() + require.NoError(t, err) + return records +} + +func csvRow(t *testing.T, headers []string, record []string) map[string]string { + t.Helper() + require.Len(t, record, len(headers)) + + row := make(map[string]string, len(headers)) + for i, header := range headers { + row[header] = record[i] + } + return row +} diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index 6f0da1b208..02023da69f 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -12,7 +12,7 @@ import ( "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/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -69,7 +69,7 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo alert, resp, err := client.Dependabot.GetRepoAlert(ctx, owner, repo, alertNumber) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to get alert with number '%d'", alertNumber), + dependabotErrMsg(fmt.Sprintf("failed to get alert with number '%d'", alertNumber), owner, repo, resp), resp, err, ), nil, nil @@ -95,6 +95,33 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo } func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "state": { + Type: "string", + Description: "Filter dependabot alerts by state. Defaults to open", + Enum: []any{"open", "fixed", "dismissed", "auto_dismissed"}, + Default: json.RawMessage(`"open"`), + }, + "severity": { + Type: "string", + Description: "Filter dependabot alerts by severity", + Enum: []any{"low", "medium", "high", "critical"}, + }, + }, + Required: []string{"owner", "repo"}, + } + WithPagination(schema) + return NewTool( ToolsetMetadataDependabot, mcp.Tool{ @@ -104,31 +131,7 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server Title: t("TOOL_LIST_DEPENDABOT_ALERTS_USER_TITLE", "List dependabot alerts"), ReadOnlyHint: true, }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "The owner of the repository.", - }, - "repo": { - Type: "string", - Description: "The name of the repository.", - }, - "state": { - Type: "string", - Description: "Filter dependabot alerts by state. Defaults to open", - Enum: []any{"open", "fixed", "dismissed", "auto_dismissed"}, - Default: json.RawMessage(`"open"`), - }, - "severity": { - Type: "string", - Description: "Filter dependabot alerts by severity", - Enum: []any{"low", "medium", "high", "critical"}, - }, - }, - Required: []string{"owner", "repo"}, - }, + InputSchema: schema, }, []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -149,6 +152,11 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server return utils.NewToolResultError(err.Error()), nil, nil } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, err @@ -157,10 +165,14 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, owner, repo, &github.ListAlertsOptions{ State: ToStringPtr(state), Severity: ToStringPtr(severity), + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), + dependabotErrMsg(fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), owner, repo, resp), resp, err, ), nil, nil @@ -184,3 +196,16 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server }, ) } + +// dependabotErrMsg enhances error messages for dependabot API failures by +// appending a hint about token permissions when the response indicates +// the token may lack access to the repository (403 or 404). +func dependabotErrMsg(base, owner, repo string, resp *github.Response) string { + if resp != nil && (resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound) { + return fmt.Sprintf("%s. Your token may not have access to Dependabot alerts on %s/%s. "+ + "To access Dependabot alerts, the token needs the 'security_events' scope or, for fine-grained tokens, "+ + "Dependabot alerts read permission for this specific repository.", + base, owner, repo) + } + return base +} diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go index e20d2668ff..7811483908 100644 --- a/pkg/github/dependabot_test.go +++ b/pkg/github/dependabot_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -66,14 +66,30 @@ func Test_GetDependabotAlert(t *testing.T) { "alertNumber": float64(9999), }, expectError: true, - expectedErrMsg: "failed to get alert", + expectedErrMsg: "Your token may not have access to Dependabot alerts on owner/repo", + }, + { + name: "alert fetch forbidden", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposDependabotAlertsByOwnerByRepoByAlertNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Resource not accessible by integration"}`)) + }), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "alertNumber": float64(42), + }, + expectError: true, + expectedErrMsg: "Your token may not have access to Dependabot alerts on owner/repo", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -149,7 +165,9 @@ func Test_ListDependabotAlerts(t *testing.T) { name: "successful open alerts listing", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ - "state": "open", + "state": "open", + "page": "1", + "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), ), @@ -167,6 +185,8 @@ func Test_ListDependabotAlerts(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ "severity": "high", + "page": "1", + "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&highSeverityAlert}), ), @@ -182,7 +202,10 @@ func Test_ListDependabotAlerts(t *testing.T) { { name: "successful all alerts listing", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{}).andThen( + GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}), ), }), @@ -193,6 +216,25 @@ func Test_ListDependabotAlerts(t *testing.T) { expectError: false, expectedAlerts: []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}, }, + { + name: "successful alerts listing with custom pagination", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "page": "3", + "per_page": "100", + }).andThen( + mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "page": float64(3), + "perPage": float64(100), + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&criticalAlert}, + }, { name: "alerts listing fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -208,11 +250,26 @@ func Test_ListDependabotAlerts(t *testing.T) { expectError: true, expectedErrMsg: "failed to list alerts", }, + { + name: "alerts listing forbidden includes token hint", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposDependabotAlertsByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Resource not accessible by integration"}`)) + }), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "Your token may not have access to Dependabot alerts on owner/repo", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index f966c531e5..1141fbce89 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net/http" "os" @@ -11,11 +12,13 @@ import ( "github.com/github/github-mcp-server/pkg/http/transport" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/lockdown" + "github.com/github/github-mcp-server/pkg/observability" + "github.com/github/github-mcp-server/pkg/observability/metrics" "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" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) @@ -94,6 +97,14 @@ type ToolDependencies interface { // IsFeatureEnabled checks if a feature flag is enabled. IsFeatureEnabled(ctx context.Context, flagName string) bool + + // Logger returns the structured logger, optionally enriched with + // request-scoped data from ctx. Integrators provide their own slog.Handler + // to control where logs are sent. + Logger(ctx context.Context) *slog.Logger + + // Metrics returns the metrics client + Metrics(ctx context.Context) metrics.Metrics } // BaseDeps is the standard implementation of ToolDependencies for the local server. @@ -113,6 +124,9 @@ type BaseDeps struct { // Feature flag checker for runtime checks featureChecker inventory.FeatureFlagChecker + + // Observability exporters (includes logger) + Obsv observability.Exporters } // Compile-time assertion to verify that BaseDeps implements the ToolDependencies interface. @@ -128,6 +142,7 @@ func NewBaseDeps( flags FeatureFlags, contentWindowSize int, featureChecker inventory.FeatureFlagChecker, + obsv observability.Exporters, ) *BaseDeps { return &BaseDeps{ Client: client, @@ -138,6 +153,7 @@ func NewBaseDeps( Flags: flags, ContentWindowSize: contentWindowSize, featureChecker: featureChecker, + Obsv: obsv, } } @@ -170,6 +186,16 @@ func (d BaseDeps) GetFlags(_ context.Context) FeatureFlags { return d.Flags } // GetContentWindowSize implements ToolDependencies. func (d BaseDeps) GetContentWindowSize() int { return d.ContentWindowSize } +// Logger implements ToolDependencies. +func (d BaseDeps) Logger(_ context.Context) *slog.Logger { + return d.Obsv.Logger() +} + +// Metrics implements ToolDependencies. +func (d BaseDeps) Metrics(ctx context.Context) metrics.Metrics { + return d.Obsv.Metrics(ctx) +} + // 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. @@ -227,7 +253,7 @@ func NewToolFromHandler( 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) { + st := inventory.NewServerTool(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { deps := MustDepsFromContext(ctx) return handler(ctx, deps, req) }) @@ -247,6 +273,9 @@ type RequestDeps struct { // Feature flag checker for runtime checks featureChecker inventory.FeatureFlagChecker + + // Observability exporters (includes logger) + obsv observability.Exporters } // NewRequestDeps creates a RequestDeps with the provided clients and configuration. @@ -258,6 +287,7 @@ func NewRequestDeps( t translations.TranslationHelperFunc, contentWindowSize int, featureChecker inventory.FeatureFlagChecker, + obsv observability.Exporters, ) *RequestDeps { return &RequestDeps{ apiHosts: apiHosts, @@ -267,6 +297,7 @@ func NewRequestDeps( T: t, ContentWindowSize: contentWindowSize, featureChecker: featureChecker, + obsv: obsv, } } @@ -289,10 +320,14 @@ func (d *RequestDeps) GetClient(ctx context.Context) (*gogithub.Client, error) { } // Construct REST client - restClient := gogithub.NewClient(nil).WithAuthToken(token) - restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", d.version) - restClient.BaseURL = baseRestURL - restClient.UploadURL = uploadURL + restClient, err := gogithub.NewClient( + gogithub.WithAuthToken(token), + gogithub.WithUserAgent(fmt.Sprintf("github-mcp-server/%s", d.version)), + gogithub.WithEnterpriseURLs(baseRestURL.String(), uploadURL.String()), + ) + if err != nil { + return nil, fmt.Errorf("failed to create REST client: %w", err) + } return restClient, nil } @@ -339,7 +374,10 @@ func (d *RequestDeps) GetRawClient(ctx context.Context) (*raw.Client, error) { return nil, fmt.Errorf("failed to get Raw URL: %w", err) } - rawClient := raw.NewClient(client, rawURL) + rawClient, err := raw.NewClient(client, rawURL) + if err != nil { + return nil, fmt.Errorf("failed to create raw client: %w", err) + } return rawClient, nil } @@ -355,8 +393,13 @@ func (d *RequestDeps) GetRepoAccessCache(ctx context.Context) (*lockdown.RepoAcc return nil, err } + restClient, err := d.GetClient(ctx) + if err != nil { + return nil, err + } + // Create repo access cache - instance := lockdown.GetInstance(gqlClient, d.RepoAccessOpts...) + instance := lockdown.NewRepoAccessCache(gqlClient, restClient, d.RepoAccessOpts...) return instance, nil } @@ -367,13 +410,22 @@ func (d *RequestDeps) GetT() translations.TranslationHelperFunc { return d.T } func (d *RequestDeps) GetFlags(ctx context.Context) FeatureFlags { return FeatureFlags{ LockdownMode: d.lockdownMode && ghcontext.IsLockdownMode(ctx), - InsidersMode: ghcontext.IsInsidersMode(ctx), } } // GetContentWindowSize implements ToolDependencies. func (d *RequestDeps) GetContentWindowSize() int { return d.ContentWindowSize } +// Logger implements ToolDependencies. +func (d *RequestDeps) Logger(_ context.Context) *slog.Logger { + return d.obsv.Logger() +} + +// Metrics implements ToolDependencies. +func (d *RequestDeps) Metrics(ctx context.Context) metrics.Metrics { + return d.obsv.Metrics(ctx) +} + // IsFeatureEnabled checks if a feature flag is enabled. func (d *RequestDeps) IsFeatureEnabled(ctx context.Context, flagName string) bool { if d.featureChecker == nil || flagName == "" { diff --git a/pkg/github/dependencies_test.go b/pkg/github/dependencies_test.go index d13160d4c6..1d747cae47 100644 --- a/pkg/github/dependencies_test.go +++ b/pkg/github/dependencies_test.go @@ -3,13 +3,21 @@ package github_test import ( "context" "errors" + "log/slog" "testing" "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/observability" + "github.com/github/github-mcp-server/pkg/observability/metrics" "github.com/github/github-mcp-server/pkg/translations" "github.com/stretchr/testify/assert" ) +func testExporters() observability.Exporters { + obs, _ := observability.NewExporters(slog.New(slog.DiscardHandler), metrics.NewNoopMetrics()) + return obs +} + func TestIsFeatureEnabled_WithEnabledFlag(t *testing.T) { t.Parallel() @@ -28,6 +36,7 @@ func TestIsFeatureEnabled_WithEnabledFlag(t *testing.T) { github.FeatureFlags{}, 0, // contentWindowSize checker, // featureChecker + testExporters(), ) // Test enabled flag @@ -52,6 +61,7 @@ func TestIsFeatureEnabled_WithoutChecker(t *testing.T) { github.FeatureFlags{}, 0, // contentWindowSize nil, // featureChecker (nil) + testExporters(), ) // Should return false when checker is nil @@ -76,6 +86,7 @@ func TestIsFeatureEnabled_EmptyFlagName(t *testing.T) { github.FeatureFlags{}, 0, // contentWindowSize checker, // featureChecker + testExporters(), ) // Should return false for empty flag name @@ -100,6 +111,7 @@ func TestIsFeatureEnabled_CheckerError(t *testing.T) { github.FeatureFlags{}, 0, // contentWindowSize checker, // featureChecker + testExporters(), ) // Should return false and log error (not crash) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 700560b475..514a2d030d 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -4,13 +4,14 @@ import ( "context" "encoding/json" "fmt" + "strings" "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" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -405,6 +406,10 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve Type: "number", Description: "Discussion Number", }, + "includeReplies": { + Type: "boolean", + Description: "When true, each top-level comment will include its replies nested within it (up to 100 replies per comment, which is the GitHub API maximum). Defaults to false.", + }, }, Required: []string{"owner", "repo", "discussionNumber"}, }), @@ -421,6 +426,11 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve return utils.NewToolResultError(err.Error()), nil, nil } + includeReplies, err := OptionalParam[bool](args, "includeReplies") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + // Get pagination parameters and convert to GraphQL format pagination, err := OptionalCursorPaginationParams(args) if err != nil { @@ -447,24 +457,6 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } - var q struct { - Repository struct { - Discussion struct { - Comments struct { - Nodes []struct { - Body githubv4.String - } - PageInfo struct { - HasNextPage githubv4.Boolean - HasPreviousPage githubv4.Boolean - StartCursor githubv4.String - EndCursor githubv4.String - } - TotalCount int - } `graphql:"comments(first: $first, after: $after)"` - } `graphql:"discussion(number: $discussionNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } vars := map[string]any{ "owner": githubv4.String(params.Owner), "repo": githubv4.String(params.Repo), @@ -476,25 +468,111 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve } else { vars["after"] = (*githubv4.String)(nil) } - if err := client.Query(ctx, &q, vars); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + + var comments []MinimalDiscussionComment + var pageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String } + var totalCount int - var comments []*github.IssueComment - for _, c := range q.Repository.Discussion.Comments.Nodes { - comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))}) + if includeReplies { + var q struct { + Repository struct { + Discussion struct { + Comments struct { + Nodes []struct { + ID githubv4.ID + Body githubv4.String + IsAnswer githubv4.Boolean + Replies struct { + Nodes []struct { + ID githubv4.ID + Body githubv4.String + IsAnswer githubv4.Boolean + } + TotalCount int + } `graphql:"replies(first: 100)"` + } + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int + } `graphql:"comments(first: $first, after: $after)"` + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + if err := client.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + for _, c := range q.Repository.Discussion.Comments.Nodes { + comment := MinimalDiscussionComment{ + ID: fmt.Sprintf("%v", c.ID), + Body: string(c.Body), + IsAnswer: bool(c.IsAnswer), + ReplyTotalCount: c.Replies.TotalCount, + } + for _, r := range c.Replies.Nodes { + comment.Replies = append(comment.Replies, MinimalDiscussionComment{ + ID: fmt.Sprintf("%v", r.ID), + Body: string(r.Body), + IsAnswer: bool(r.IsAnswer), + }) + } + comments = append(comments, comment) + } + pageInfo = q.Repository.Discussion.Comments.PageInfo + totalCount = q.Repository.Discussion.Comments.TotalCount + } else { + var q struct { + Repository struct { + Discussion struct { + Comments struct { + Nodes []struct { + ID githubv4.ID + Body githubv4.String + IsAnswer githubv4.Boolean + } + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int + } `graphql:"comments(first: $first, after: $after)"` + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + if err := client.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + for _, c := range q.Repository.Discussion.Comments.Nodes { + comments = append(comments, MinimalDiscussionComment{ + ID: fmt.Sprintf("%v", c.ID), + Body: string(c.Body), + IsAnswer: bool(c.IsAnswer), + }) + } + pageInfo = q.Repository.Discussion.Comments.PageInfo + totalCount = q.Repository.Discussion.Comments.TotalCount } // Create response with pagination info response := map[string]any{ "comments": comments, "pageInfo": map[string]any{ - "hasNextPage": q.Repository.Discussion.Comments.PageInfo.HasNextPage, - "hasPreviousPage": q.Repository.Discussion.Comments.PageInfo.HasPreviousPage, - "startCursor": string(q.Repository.Discussion.Comments.PageInfo.StartCursor), - "endCursor": string(q.Repository.Discussion.Comments.PageInfo.EndCursor), + "hasNextPage": pageInfo.HasNextPage, + "hasPreviousPage": pageInfo.HasPreviousPage, + "startCursor": string(pageInfo.StartCursor), + "endCursor": string(pageInfo.EndCursor), }, - "totalCount": q.Repository.Discussion.Comments.TotalCount, + "totalCount": totalCount, } out, err := json.Marshal(response) @@ -507,6 +585,409 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve ) } +func DiscussionCommentWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataDiscussions, + mcp.Tool{ + Name: "discussion_comment_write", + Description: t("TOOL_DISCUSSION_COMMENT_WRITE_DESCRIPTION", `Write operations for discussion comments. +Supports adding top-level comments, replying to existing comments, updating comment content, deleting comments, and marking or unmarking comments as the answer.`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_DISCUSSION_COMMENT_WRITE_USER_TITLE", "Manage discussion comments"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: `Write operation to perform on a discussion comment. +Options are: +- 'add' - adds a new top-level comment to a discussion. +- 'reply' - replies to a top-level discussion comment (GitHub Discussions only support one level of nesting). +- 'update' - updates an existing discussion comment. +- 'delete' - deletes a discussion comment. +- 'mark_answer' - marks a discussion comment as the answer (Q&A only). +- 'unmark_answer' - unmarks a discussion comment as the answer (Q&A only). +`, + Enum: []any{"add", "reply", "update", "delete", "mark_answer", "unmark_answer"}, + }, + "owner": { + Type: "string", + Description: "Repository owner (required for 'add' and 'reply' methods)", + }, + "repo": { + Type: "string", + Description: "Repository name (required for 'add' and 'reply' methods)", + }, + "discussionNumber": { + Type: "number", + Description: "Discussion number (required for 'add' and 'reply' methods)", + }, + "body": { + Type: "string", + Description: "Comment content (required for 'add', 'reply', and 'update' methods)", + }, + "commentNodeID": { + Type: "string", + Description: "The Node ID of the discussion comment (required for 'reply', 'update', 'delete', 'mark_answer', and 'unmark_answer' methods). For 'reply', this is the top-level comment to reply to; GitHub Discussions only support one level of nesting.", + }, + }, + Required: []string{"method"}, + }, + }, + []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 { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil + } + + switch method { + case "add": + return addDiscussionComment(ctx, client, args) + case "reply": + return replyToDiscussionComment(ctx, client, args) + case "update": + return updateDiscussionComment(ctx, client, args) + case "delete": + return deleteDiscussionComment(ctx, client, args) + case "mark_answer": + return markDiscussionCommentAsAnswer(ctx, client, args) + case "unmark_answer": + return unmarkDiscussionCommentAsAnswer(ctx, client, args) + default: + return utils.NewToolResultError("invalid method, must be one of: 'add', 'reply', 'update', 'delete', 'mark_answer', 'unmark_answer'"), nil, nil + } + }) +} + +func addDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + discussionNumber, err := RequiredInt(args, "discussionNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, err := RequiredParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Get the discussion's node ID using its number + var q struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "discussionNumber": githubv4.Int(discussionNumber), // #nosec G115 - discussion numbers are always small positive integers + } + if err := client.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.AddDiscussionCommentInput{ + DiscussionID: q.Repository.Discussion.ID, + Body: githubv4.String(body), + } + + var mutation struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + comment := mutation.AddDiscussionComment.Comment + out, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", comment.ID), + URL: string(comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func requiredCommentNodeID(args map[string]any) (string, error) { + commentNodeID, err := RequiredParam[string](args, "commentNodeID") + if err != nil { + return "", err + } + if strings.TrimSpace(commentNodeID) == "" { + return "", fmt.Errorf("commentNodeID cannot be blank") + } + return commentNodeID, nil +} + +func replyToDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + 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 + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + discussionNumber, err := RequiredInt(args, "discussionNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, err := RequiredParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // The GitHub API silently ignores an invalid ReplyToID and creates a top-level + // comment instead of returning an error, so we validate upfront that the node + // exists and is a DiscussionComment to give callers a clear failure. + var nodeQuery struct { + Node struct { + DiscussionComment struct { + ID *githubv4.ID + Discussion struct { + ID githubv4.ID + } `graphql:"discussion"` + } `graphql:"... on DiscussionComment"` + } `graphql:"node(id: $replyToID)"` + } + if err := client.Query(ctx, &nodeQuery, map[string]any{ + "replyToID": githubv4.ID(commentNodeID), + }); err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to validate commentNodeID: %v", err)), nil, nil + } + if nodeQuery.Node.DiscussionComment.ID == nil || *nodeQuery.Node.DiscussionComment.ID == "" { + return utils.NewToolResultError(fmt.Sprintf("commentNodeID %q does not resolve to a valid discussion comment", commentNodeID)), nil, nil + } + + // Get the discussion's node ID using its number + var q struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "discussionNumber": githubv4.Int(discussionNumber), // #nosec G115 - discussion numbers are always small positive integers + } + if err := client.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if nodeQuery.Node.DiscussionComment.Discussion.ID != q.Repository.Discussion.ID { + return utils.NewToolResultError( + fmt.Sprintf("commentNodeID %q does not belong to discussion #%d in %s/%s", commentNodeID, discussionNumber, owner, repo), + ), nil, nil + } + + replyToID := githubv4.ID(commentNodeID) + input := githubv4.AddDiscussionCommentInput{ + DiscussionID: nodeQuery.Node.DiscussionComment.Discussion.ID, + Body: githubv4.String(body), + ReplyToID: &replyToID, + } + + var mutation struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + comment := mutation.AddDiscussionComment.Comment + out, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", comment.ID), + URL: string(comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func updateDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, err := RequiredParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.UpdateDiscussionCommentInput{ + CommentID: githubv4.ID(commentNodeID), + Body: githubv4.String(body), + } + + var mutation struct { + UpdateDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + comment := mutation.UpdateDiscussionComment.Comment + out, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", comment.ID), + URL: string(comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func deleteDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.DeleteDiscussionCommentInput{ + ID: githubv4.ID(commentNodeID), + } + + var mutation struct { + DeleteDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"deleteDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + comment := mutation.DeleteDiscussionComment.Comment + out, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", comment.ID), + URL: string(comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func markDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.MarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID(commentNodeID), + } + var mutation struct { + MarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"markDiscussionCommentAsAnswer(input: $input)"` + } + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + out, err := json.Marshal(struct { + DiscussionID string `json:"discussionID"` + DiscussionURL string `json:"discussionURL"` + }{ + DiscussionID: fmt.Sprintf("%v", mutation.MarkDiscussionCommentAsAnswer.Discussion.ID), + DiscussionURL: string(mutation.MarkDiscussionCommentAsAnswer.Discussion.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func unmarkDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.UnmarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID(commentNodeID), + } + var mutation struct { + UnmarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"unmarkDiscussionCommentAsAnswer(input: $input)"` + } + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + out, err := json.Marshal(struct { + DiscussionID string `json:"discussionID"` + DiscussionURL string `json:"discussionURL"` + }{ + DiscussionID: fmt.Sprintf("%v", mutation.UnmarkDiscussionCommentAsAnswer.Discussion.ID), + DiscussionURL: string(mutation.UnmarkDiscussionCommentAsAnswer.Discussion.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataDiscussions, diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 692ef2ec83..36fdb6c43a 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -647,10 +647,11 @@ func Test_GetDiscussionComments(t *testing.T) { assert.Contains(t, schema.Properties, "owner") assert.Contains(t, schema.Properties, "repo") assert.Contains(t, schema.Properties, "discussionNumber") + assert.Contains(t, schema.Properties, "includeReplies") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "discussionNumber"}) // Use exact string query that matches implementation output - qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{body},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" + qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{id,body,isAnswer},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" // Variables matching what GraphQL receives after JSON marshaling/unmarshaling vars := map[string]any{ @@ -666,8 +667,8 @@ func Test_GetDiscussionComments(t *testing.T) { "discussion": map[string]any{ "comments": map[string]any{ "nodes": []map[string]any{ - {"body": "This is the first comment"}, - {"body": "This is the second comment"}, + {"id": "DC_id1", "body": "This is the first comment"}, + {"id": "DC_id2", "body": "This is the second comment"}, }, "pageInfo": map[string]any{ "hasNextPage": false, @@ -701,7 +702,10 @@ func Test_GetDiscussionComments(t *testing.T) { // (Lines removed) var response struct { - Comments []*github.IssueComment `json:"comments"` + Comments []struct { + ID string `json:"id"` + Body string `json:"body"` + } `json:"comments"` PageInfo struct { HasNextPage bool `json:"hasNextPage"` HasPreviousPage bool `json:"hasPreviousPage"` @@ -713,17 +717,17 @@ func Test_GetDiscussionComments(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.Len(t, response.Comments, 2) - expectedBodies := []string{"This is the first comment", "This is the second comment"} - for i, comment := range response.Comments { - assert.Equal(t, expectedBodies[i], *comment.Body) - } + assert.Equal(t, "DC_id1", response.Comments[0].ID) + assert.Equal(t, "This is the first comment", response.Comments[0].Body) + assert.Equal(t, "DC_id2", response.Comments[1].ID) + assert.Equal(t, "This is the second comment", response.Comments[1].Body) } func Test_GetDiscussionCommentsWithStringNumber(t *testing.T) { // Test that WeakDecode handles string discussionNumber from MCP clients toolDef := GetDiscussionComments(translations.NullTranslationHelper) - qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{body},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" + qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{id,body,isAnswer},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" vars := map[string]any{ "owner": "owner", @@ -738,7 +742,7 @@ func Test_GetDiscussionCommentsWithStringNumber(t *testing.T) { "discussion": map[string]any{ "comments": map[string]any{ "nodes": []map[string]any{ - {"body": "First comment"}, + {"id": "DC_id3", "body": "First comment"}, }, "pageInfo": map[string]any{ "hasNextPage": false, @@ -777,6 +781,7 @@ func Test_GetDiscussionCommentsWithStringNumber(t *testing.T) { } require.NoError(t, json.Unmarshal([]byte(textContent.Text), &out)) assert.Len(t, out.Comments, 1) + assert.Equal(t, "DC_id3", out.Comments[0]["id"]) assert.Equal(t, "First comment", out.Comments[0]["body"]) } @@ -924,3 +929,896 @@ func Test_ListDiscussionCategories(t *testing.T) { }) } } + +func Test_DiscussionCommentWrite(t *testing.T) { + t.Parallel() + + toolDef := DiscussionCommentWrite(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "discussion_comment_write", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint, "discussion_comment_write should not be read-only") + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.True(t, *tool.Annotations.DestructiveHint, "discussion_comment_write should be destructive") + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "method") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "discussionNumber") + assert.Contains(t, schema.Properties, "body") + assert.Contains(t, schema.Properties, "commentNodeID") + assert.ElementsMatch(t, schema.Required, []string{"method"}) + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "method: missing", + requestArgs: map[string]any{}, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: method", + }, + { + name: "invalid method", + requestArgs: map[string]any{ + "method": "invalid", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "invalid method, must be one of: 'add', 'reply', 'update', 'delete', 'mark_answer', 'unmark_answer'", + }, + }) +} + +func Test_DiscussionCommentWrite_Add(t *testing.T) { + t.Parallel() + + discussionQueryMatcher := discussionCommentWriteDiscussionQueryMatcher( + 1, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussion": map[string]any{ + "id": "D_kwDOTest123", + }, + }, + }), + ) + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "add: successful comment creation", + requestArgs: map[string]any{ + "method": "add", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a test comment", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionQueryMatcher, + githubv4mock.NewMutationMatcher( + struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + }{}, + githubv4.AddDiscussionCommentInput{ + DiscussionID: githubv4.ID("D_kwDOTest123"), + Body: githubv4.String("This is a test comment"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "addDiscussionComment": map[string]any{ + "comment": map[string]any{ + "id": "DC_kwDOComment456", + "url": "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + }, + }), + ), + ), + expectedID: "DC_kwDOComment456", + expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + { + name: "add: discussion not found", + requestArgs: map[string]any{ + "method": "add", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(999), + "body": "This is a comment", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "discussionNumber": githubv4.Int(999), + }, + githubv4mock.ErrorResponse("Could not resolve to a Discussion with the number of 999."), + ), + ), + expectToolError: true, + expectedErrMsg: "Could not resolve to a Discussion with the number of 999.", + }, + { + name: "add: mutation failure", + requestArgs: map[string]any{ + "method": "add", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a comment", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionQueryMatcher, + githubv4mock.NewMutationMatcher( + struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + }{}, + githubv4.AddDiscussionCommentInput{ + DiscussionID: githubv4.ID("D_kwDOTest123"), + Body: githubv4.String("This is a comment"), + }, + nil, + githubv4mock.ErrorResponse("insufficient permissions to comment on this discussion"), + ), + ), + expectToolError: true, + expectedErrMsg: "insufficient permissions to comment on this discussion", + }, + { + name: "add: missing body", + requestArgs: map[string]any{ + "method": "add", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: body", + }, + }) +} + +func Test_DiscussionCommentWrite_Reply(t *testing.T) { + t.Parallel() + + discussionQueryMatcher := discussionCommentWriteDiscussionQueryMatcher( + 1, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussion": map[string]any{ + "id": "D_kwDOTest123", + }, + }, + }), + ) + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "reply: successful reply to comment", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionCommentWriteReplyValidationQueryMatcher( + "DC_kwDOComment456", + githubv4mock.DataResponse(map[string]any{ + "node": map[string]any{ + "id": "DC_kwDOComment456", + "discussion": map[string]any{ + "id": "D_kwDOTest123", + }, + }, + }), + ), + discussionQueryMatcher, + githubv4mock.NewMutationMatcher( + struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + }{}, + githubv4.AddDiscussionCommentInput{ + DiscussionID: githubv4.ID("D_kwDOTest123"), + Body: githubv4.String("This is a reply"), + ReplyToID: githubv4ptr("DC_kwDOComment456"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "addDiscussionComment": map[string]any{ + "comment": map[string]any{ + "id": "DC_kwDOReply789", + "url": "https://github.com/owner/repo/discussions/1#discussioncomment-789", + }, + }, + }), + ), + ), + expectedID: "DC_kwDOReply789", + expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-789", + }, + { + name: "reply: missing commentNodeID", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: commentNodeID", + }, + { + name: "reply: whitespace-only commentNodeID is rejected", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + "commentNodeID": " ", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "commentNodeID cannot be blank", + }, + { + name: "reply: invalid commentNodeID returns error", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + "commentNodeID": "DC_kwDOInvalid", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionCommentWriteReplyValidationQueryMatcher( + "DC_kwDOInvalid", + githubv4mock.DataResponse(map[string]any{ + "node": nil, + }), + ), + ), + expectToolError: true, + expectedErrMsg: `commentNodeID "DC_kwDOInvalid" does not resolve to a valid discussion comment`, + }, + { + name: "reply: comment from another discussion is rejected", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionCommentWriteReplyValidationQueryMatcher( + "DC_kwDOComment456", + githubv4mock.DataResponse(map[string]any{ + "node": map[string]any{ + "id": "DC_kwDOComment456", + "discussion": map[string]any{ + "id": "D_kwDOOtherDiscussion456", + }, + }, + }), + ), + discussionQueryMatcher, + ), + expectToolError: true, + expectedErrMsg: `commentNodeID "DC_kwDOComment456" does not belong to discussion #1 in owner/repo`, + }, + { + name: "reply: validation query failure", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionCommentWriteReplyValidationQueryMatcher( + "DC_kwDOComment456", + githubv4mock.ErrorResponse("Could not resolve to a node with the global id of 'DC_kwDOComment456'."), + ), + ), + expectToolError: true, + expectedErrMsg: "failed to validate commentNodeID: Could not resolve to a node with the global id of 'DC_kwDOComment456'.", + }, + }) +} + +func Test_DiscussionCommentWrite_Update(t *testing.T) { + t.Parallel() + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "update: successful comment update", + requestArgs: map[string]any{ + "method": "update", + "commentNodeID": "DC_kwDOComment456", + "body": "Updated comment text", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UpdateDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussionComment(input: $input)"` + }{}, + githubv4.UpdateDiscussionCommentInput{ + CommentID: githubv4.ID("DC_kwDOComment456"), + Body: githubv4.String("Updated comment text"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateDiscussionComment": map[string]any{ + "comment": map[string]any{ + "id": "DC_kwDOComment456", + "url": "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + }, + }), + ), + ), + expectedID: "DC_kwDOComment456", + expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + { + name: "update: comment not found", + requestArgs: map[string]any{ + "method": "update", + "commentNodeID": "DC_kwDOInvalid", + "body": "Updated comment text", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UpdateDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussionComment(input: $input)"` + }{}, + githubv4.UpdateDiscussionCommentInput{ + CommentID: githubv4.ID("DC_kwDOInvalid"), + Body: githubv4.String("Updated comment text"), + }, + nil, + githubv4mock.ErrorResponse("Could not resolve to a node with the global id of 'DC_kwDOInvalid'."), + ), + ), + expectToolError: true, + expectedErrMsg: "Could not resolve to a node with the global id of 'DC_kwDOInvalid'.", + }, + { + name: "update: insufficient permissions", + requestArgs: map[string]any{ + "method": "update", + "commentNodeID": "DC_kwDOComment456", + "body": "Updated comment text", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UpdateDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussionComment(input: $input)"` + }{}, + githubv4.UpdateDiscussionCommentInput{ + CommentID: githubv4.ID("DC_kwDOComment456"), + Body: githubv4.String("Updated comment text"), + }, + nil, + githubv4mock.ErrorResponse("insufficient permissions to update this discussion comment"), + ), + ), + expectToolError: true, + expectedErrMsg: "insufficient permissions to update this discussion comment", + }, + { + name: "update: missing commentNodeID", + requestArgs: map[string]any{ + "method": "update", + "body": "Updated comment text", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: commentNodeID", + }, + { + name: "update: whitespace-only commentNodeID is rejected", + requestArgs: map[string]any{ + "method": "update", + "commentNodeID": " ", + "body": "Updated comment text", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "commentNodeID cannot be blank", + }, + { + name: "update: missing body", + requestArgs: map[string]any{ + "method": "update", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: body", + }, + }) +} + +func Test_DiscussionCommentWrite_Delete(t *testing.T) { + t.Parallel() + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "delete: successful comment delete", + requestArgs: map[string]any{ + "method": "delete", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + DeleteDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"deleteDiscussionComment(input: $input)"` + }{}, + githubv4.DeleteDiscussionCommentInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "deleteDiscussionComment": map[string]any{ + "comment": map[string]any{ + "id": "DC_kwDOComment456", + "url": "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + }, + }), + ), + ), + expectedID: "DC_kwDOComment456", + expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + { + name: "delete: comment not found", + requestArgs: map[string]any{ + "method": "delete", + "commentNodeID": "DC_kwDOInvalid", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + DeleteDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"deleteDiscussionComment(input: $input)"` + }{}, + githubv4.DeleteDiscussionCommentInput{ + ID: githubv4.ID("DC_kwDOInvalid"), + }, + nil, + githubv4mock.ErrorResponse("Could not resolve to a node with the global id of 'DC_kwDOInvalid'."), + ), + ), + expectToolError: true, + expectedErrMsg: "Could not resolve to a node with the global id of 'DC_kwDOInvalid'.", + }, + { + name: "delete: insufficient permissions", + requestArgs: map[string]any{ + "method": "delete", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + DeleteDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"deleteDiscussionComment(input: $input)"` + }{}, + githubv4.DeleteDiscussionCommentInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.ErrorResponse("insufficient permissions to delete this discussion comment"), + ), + ), + expectToolError: true, + expectedErrMsg: "insufficient permissions to delete this discussion comment", + }, + { + name: "delete: missing commentNodeID", + requestArgs: map[string]any{ + "method": "delete", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: commentNodeID", + }, + }) +} + +func Test_DiscussionCommentWrite_MarkAnswer(t *testing.T) { + t.Parallel() + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "mark_answer: successful mark as answer", + requestArgs: map[string]any{ + "method": "mark_answer", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + MarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"markDiscussionCommentAsAnswer(input: $input)"` + }{}, + githubv4.MarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "markDiscussionCommentAsAnswer": map[string]any{ + "discussion": map[string]any{ + "id": "D_kwDOTest123", + "url": "https://github.com/owner/repo/discussions/1", + }, + }, + }), + ), + ), + expectedDiscussionID: "D_kwDOTest123", + expectedDiscussionURL: "https://github.com/owner/repo/discussions/1", + }, + { + name: "mark_answer: mutation failure", + requestArgs: map[string]any{ + "method": "mark_answer", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + MarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"markDiscussionCommentAsAnswer(input: $input)"` + }{}, + githubv4.MarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.ErrorResponse("discussion is not a Q&A discussion"), + ), + ), + expectToolError: true, + expectedErrMsg: "discussion is not a Q&A discussion", + }, + { + name: "mark_answer: whitespace-only commentNodeID is rejected", + requestArgs: map[string]any{ + "method": "mark_answer", + "commentNodeID": " ", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "commentNodeID cannot be blank", + }, + }) +} + +func Test_DiscussionCommentWrite_UnmarkAnswer(t *testing.T) { + t.Parallel() + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "unmark_answer: successful unmark as answer", + requestArgs: map[string]any{ + "method": "unmark_answer", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UnmarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"unmarkDiscussionCommentAsAnswer(input: $input)"` + }{}, + githubv4.UnmarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "unmarkDiscussionCommentAsAnswer": map[string]any{ + "discussion": map[string]any{ + "id": "D_kwDOTest123", + "url": "https://github.com/owner/repo/discussions/1", + }, + }, + }), + ), + ), + expectedDiscussionID: "D_kwDOTest123", + expectedDiscussionURL: "https://github.com/owner/repo/discussions/1", + }, + { + name: "unmark_answer: mutation failure", + requestArgs: map[string]any{ + "method": "unmark_answer", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UnmarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"unmarkDiscussionCommentAsAnswer(input: $input)"` + }{}, + githubv4.UnmarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.ErrorResponse("insufficient permissions"), + ), + ), + expectToolError: true, + expectedErrMsg: "insufficient permissions", + }, + }) +} + +type discussionCommentWriteTestCase struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedErrMsg string + expectedID string + expectedURL string + expectedDiscussionID string + expectedDiscussionURL string +} + +func runDiscussionCommentWriteTests(t *testing.T, tests []discussionCommentWriteTestCase) { + t.Helper() + + toolDef := DiscussionCommentWrite(translations.NullTranslationHelper) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gqlClient := githubv4.NewClient(tc.mockedClient) + deps := BaseDeps{GQLClient: gqlClient} + handler := toolDef.Handler(deps) + + req := createMCPRequest(tc.requestArgs) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + + text := getTextResult(t, res).Text + + if tc.expectToolError { + require.True(t, res.IsError) + assert.Contains(t, text, tc.expectedErrMsg) + return + } + + require.False(t, res.IsError) + + if tc.expectedDiscussionID != "" { + var response struct { + DiscussionID string `json:"discussionID"` + DiscussionURL string `json:"discussionURL"` + } + require.NoError(t, json.Unmarshal([]byte(text), &response)) + assert.Equal(t, tc.expectedDiscussionID, response.DiscussionID) + assert.Equal(t, tc.expectedDiscussionURL, response.DiscussionURL) + } else { + var response MinimalResponse + require.NoError(t, json.Unmarshal([]byte(text), &response)) + assert.Equal(t, tc.expectedID, response.ID) + assert.Equal(t, tc.expectedURL, response.URL) + } + }) + } +} + +func discussionCommentWriteDiscussionQueryMatcher(discussionNumber int32, response githubv4mock.GQLResponse) githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "discussionNumber": githubv4.Int(discussionNumber), + }, + response, + ) +} + +func discussionCommentWriteReplyValidationQueryMatcher(commentNodeID string, response githubv4mock.GQLResponse) githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + struct { + Node struct { + DiscussionComment struct { + ID *githubv4.ID + Discussion struct { + ID githubv4.ID + } `graphql:"discussion"` + } `graphql:"... on DiscussionComment"` + } `graphql:"node(id: $replyToID)"` + }{}, + map[string]any{ + "replyToID": githubv4.ID(commentNodeID), + }, + response, + ) +} + +func githubv4ptr(id githubv4.ID) *githubv4.ID { + return &id +} + +func Test_GetDiscussionCommentsWithReplies(t *testing.T) { + t.Parallel() + + toolDef := GetDiscussionComments(translations.NullTranslationHelper) + + qWithReplies := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{id,body,isAnswer,replies(first: 100){nodes{id,body,isAnswer},totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" + + vars := map[string]any{ + "owner": "owner", + "repo": "repo", + "discussionNumber": float64(1), + "first": float64(30), + "after": (*string)(nil), + } + + mockResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussion": map[string]any{ + "comments": map[string]any{ + "nodes": []map[string]any{ + { + "id": "DC_id1", + "body": "Top-level comment", + "replies": map[string]any{ + "nodes": []map[string]any{ + {"id": "DC_reply1", "body": "Reply to first comment", "isAnswer": true}, + }, + "totalCount": 1, + }, + }, + { + "id": "DC_id2", + "body": "Another top-level comment", + "replies": map[string]any{ + "nodes": []map[string]any{}, + "totalCount": 0, + }, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 2, + }, + }, + }, + }) + + matcher := githubv4mock.NewQueryMatcher(qWithReplies, vars, mockResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + deps := BaseDeps{GQLClient: gqlClient} + handler := toolDef.Handler(deps) + + reqParams := map[string]any{ + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "includeReplies": true, + } + req := createMCPRequest(reqParams) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + + text := getTextResult(t, res).Text + require.False(t, res.IsError, "expected no error, got: %s", text) + + var response struct { + Comments []MinimalDiscussionComment `json:"comments"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + } `json:"pageInfo"` + TotalCount int `json:"totalCount"` + } + require.NoError(t, json.Unmarshal([]byte(text), &response)) + assert.Len(t, response.Comments, 2) + + assert.Equal(t, "DC_id1", response.Comments[0].ID) + assert.Equal(t, "Top-level comment", response.Comments[0].Body) + require.Len(t, response.Comments[0].Replies, 1) + assert.Equal(t, "DC_reply1", response.Comments[0].Replies[0].ID) + assert.Equal(t, "Reply to first comment", response.Comments[0].Replies[0].Body) + assert.True(t, response.Comments[0].Replies[0].IsAnswer) + assert.Equal(t, 1, response.Comments[0].ReplyTotalCount) + + assert.Equal(t, "DC_id2", response.Comments[1].ID) + assert.Empty(t, response.Comments[1].Replies) + assert.Equal(t, 0, response.Comments[1].ReplyTotalCount) +} diff --git a/pkg/github/dynamic_tools.go b/pkg/github/dynamic_tools.go deleted file mode 100644 index 5c7d31d4ea..0000000000 --- a/pkg/github/dynamic_tools.go +++ /dev/null @@ -1,217 +0,0 @@ -package github - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/jsonschema-go/jsonschema" - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// DynamicToolDependencies contains dependencies for dynamic toolset management tools. -// It includes the managed Inventory, the server for registration, and the deps -// that will be passed to tools when they are dynamically enabled. -type DynamicToolDependencies struct { - // Server is the MCP server to register tools with - Server *mcp.Server - // Inventory contains all available tools, resources and prompts that can be enabled dynamically - Inventory *inventory.Inventory - // ToolDeps are the dependencies passed to tools when they are registered - ToolDeps any - // T is the translation helper function - T translations.TranslationHelperFunc -} - -// NewDynamicTool creates a ServerTool with fully-typed DynamicToolDependencies. -// Dynamic tools use a different dependency structure (DynamicToolDependencies) than regular -// tools (ToolDependencies), so they intentionally use the closure pattern. -func NewDynamicTool(toolset inventory.ToolsetMetadata, tool mcp.Tool, handler func(deps DynamicToolDependencies) mcp.ToolHandlerFor[map[string]any, any]) inventory.ServerTool { - //nolint:staticcheck // SA1019: Dynamic tools use a different deps structure, closure pattern is intentional - return inventory.NewServerTool(tool, toolset, func(d any) mcp.ToolHandlerFor[map[string]any, any] { - return handler(d.(DynamicToolDependencies)) - }) -} - -// toolsetIDsEnum returns the list of toolset IDs as an enum for JSON Schema. -func toolsetIDsEnum(r *inventory.Inventory) []any { - toolsetIDs := r.ToolsetIDs() - result := make([]any, len(toolsetIDs)) - for i, id := range toolsetIDs { - result[i] = id - } - return result -} - -// DynamicTools returns the tools for dynamic toolset management. -// These tools allow runtime discovery and enablement of inventory. -// The r parameter provides the available toolset IDs for JSON Schema enums. -func DynamicTools(r *inventory.Inventory) []inventory.ServerTool { - return []inventory.ServerTool{ - ListAvailableToolsets(), - GetToolsetsTools(r), - EnableToolset(r), - } -} - -// EnableToolset creates a tool that enables a toolset at runtime. -func EnableToolset(r *inventory.Inventory) inventory.ServerTool { - return NewDynamicTool( - ToolsetMetadataDynamic, - mcp.Tool{ - Name: "enable_toolset", - Description: "Enable one of the sets of tools the GitHub MCP server provides, use get_toolset_tools and list_available_toolsets first to see what this will enable", - Annotations: &mcp.ToolAnnotations{ - Title: "Enable a toolset", - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "toolset": { - Type: "string", - Description: "The name of the toolset to enable", - Enum: toolsetIDsEnum(r), - }, - }, - Required: []string{"toolset"}, - }, - }, - func(deps DynamicToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { - return func(_ context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - toolsetName, err := RequiredParam[string](args, "toolset") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - toolsetID := inventory.ToolsetID(toolsetName) - - if !deps.Inventory.HasToolset(toolsetID) { - return utils.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil, nil - } - - if deps.Inventory.IsToolsetEnabled(toolsetID) { - return utils.NewToolResultText(fmt.Sprintf("Toolset %s is already enabled", toolsetName)), nil, nil - } - - // Mark the toolset as enabled so IsToolsetEnabled returns true - deps.Inventory.EnableToolset(toolsetID) - - // Get tools for this toolset and register them with the managed deps - toolsForToolset := deps.Inventory.ToolsForToolset(toolsetID) - for _, st := range toolsForToolset { - st.RegisterFunc(deps.Server, deps.ToolDeps) - } - - return utils.NewToolResultText(fmt.Sprintf("Toolset %s enabled with %d tools", toolsetName, len(toolsForToolset))), nil, nil - } - }, - ) -} - -// ListAvailableToolsets creates a tool that lists all available inventory. -func ListAvailableToolsets() inventory.ServerTool { - return NewDynamicTool( - ToolsetMetadataDynamic, - mcp.Tool{ - Name: "list_available_toolsets", - Description: "List all available toolsets this GitHub MCP server can offer, providing the enabled status of each. Use this when a task could be achieved with a GitHub tool and the currently available tools aren't enough. Call get_toolset_tools with these toolset names to discover specific tools you can call", - Annotations: &mcp.ToolAnnotations{ - Title: "List available toolsets", - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{}, - }, - }, - func(deps DynamicToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { - return func(_ context.Context, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { - toolsetIDs := deps.Inventory.ToolsetIDs() - descriptions := deps.Inventory.ToolsetDescriptions() - - payload := make([]map[string]string, 0, len(toolsetIDs)) - for _, id := range toolsetIDs { - t := map[string]string{ - "name": string(id), - "description": descriptions[id], - "can_enable": "true", - "currently_enabled": fmt.Sprintf("%t", deps.Inventory.IsToolsetEnabled(id)), - } - payload = append(payload, t) - } - - r, err := json.Marshal(payload) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal features: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - } - }, - ) -} - -// GetToolsetsTools creates a tool that lists all tools in a specific toolset. -func GetToolsetsTools(r *inventory.Inventory) inventory.ServerTool { - return NewDynamicTool( - ToolsetMetadataDynamic, - mcp.Tool{ - Name: "get_toolset_tools", - Description: "Lists all the capabilities that are enabled with the specified toolset, use this to get clarity on whether enabling a toolset would help you to complete a task", - Annotations: &mcp.ToolAnnotations{ - Title: "List all tools in a toolset", - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "toolset": { - Type: "string", - Description: "The name of the toolset you want to get the tools for", - Enum: toolsetIDsEnum(r), - }, - }, - Required: []string{"toolset"}, - }, - }, - func(deps DynamicToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { - return func(_ context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - toolsetName, err := RequiredParam[string](args, "toolset") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - toolsetID := inventory.ToolsetID(toolsetName) - - if !deps.Inventory.HasToolset(toolsetID) { - return utils.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil, nil - } - - // Get all tools for this toolset (ignoring current filters for discovery) - toolsInToolset := deps.Inventory.ToolsForToolset(toolsetID) - payload := make([]map[string]string, 0, len(toolsInToolset)) - - for _, st := range toolsInToolset { - tool := map[string]string{ - "name": st.Tool.Name, - "description": st.Tool.Description, - "can_enable": "true", - "toolset": toolsetName, - } - payload = append(payload, tool) - } - - r, err := json.Marshal(payload) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal features: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - } - }, - ) -} diff --git a/pkg/github/dynamic_tools_test.go b/pkg/github/dynamic_tools_test.go deleted file mode 100644 index 3e63c5d7b4..0000000000 --- a/pkg/github/dynamic_tools_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package github - -import ( - "context" - "encoding/json" - "testing" - - "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/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// createDynamicRequest creates an MCP request with the given arguments for dynamic tools. -func createDynamicRequest(args map[string]any) *mcp.CallToolRequest { - argsJSON, _ := json.Marshal(args) - return &mcp.CallToolRequest{ - Params: &mcp.CallToolParamsRaw{ - Arguments: json.RawMessage(argsJSON), - }, - } -} - -func TestDynamicTools_ListAvailableToolsets(t *testing.T) { - // Build a registry with no toolsets enabled (dynamic mode) - reg, err := NewInventory(translations.NullTranslationHelper). - WithToolsets([]string{}). - Build() - require.NoError(t, err) - - // Create a mock server - server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) - - // Create dynamic tool dependencies - deps := DynamicToolDependencies{ - Server: server, - Inventory: reg, - ToolDeps: nil, - T: translations.NullTranslationHelper, - } - - // Get the list_available_toolsets tool - tool := ListAvailableToolsets() - handler := tool.Handler(deps) - - // Call the handler - result, err := handler(context.Background(), createDynamicRequest(map[string]any{})) - require.NoError(t, err) - require.NotNil(t, result) - require.Len(t, result.Content, 1) - - // Parse the result - var toolsets []map[string]string - textContent := result.Content[0].(*mcp.TextContent) - err = json.Unmarshal([]byte(textContent.Text), &toolsets) - require.NoError(t, err) - - // Verify we got toolsets - assert.NotEmpty(t, toolsets, "should have available toolsets") - - // Find the repos toolset and verify it's not enabled - var reposToolset map[string]string - for _, ts := range toolsets { - if ts["name"] == "repos" { - reposToolset = ts - break - } - } - require.NotNil(t, reposToolset, "repos toolset should exist") - assert.Equal(t, "false", reposToolset["currently_enabled"], "repos should not be enabled initially") -} - -func TestDynamicTools_GetToolsetTools(t *testing.T) { - // Build a registry with no toolsets enabled (dynamic mode) - reg, err := NewInventory(translations.NullTranslationHelper). - WithToolsets([]string{}). - Build() - require.NoError(t, err) - - // Create a mock server - server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) - - // Create dynamic tool dependencies - deps := DynamicToolDependencies{ - Server: server, - Inventory: reg, - ToolDeps: nil, - T: translations.NullTranslationHelper, - } - - // Get the get_toolset_tools tool - tool := GetToolsetsTools(reg) - handler := tool.Handler(deps) - - // Call the handler for repos toolset - result, err := handler(context.Background(), createDynamicRequest(map[string]any{ - "toolset": "repos", - })) - require.NoError(t, err) - require.NotNil(t, result) - require.Len(t, result.Content, 1) - - // Parse the result - var tools []map[string]string - textContent := result.Content[0].(*mcp.TextContent) - err = json.Unmarshal([]byte(textContent.Text), &tools) - require.NoError(t, err) - - // Verify we got tools for the repos toolset - assert.NotEmpty(t, tools, "repos toolset should have tools") - - // Verify at least get_commit is there (a repos toolset tool) - var foundGetCommit bool - for _, tool := range tools { - if tool["name"] == "get_commit" { - foundGetCommit = true - break - } - } - assert.True(t, foundGetCommit, "get_commit should be in repos toolset") -} - -func TestDynamicTools_EnableToolset(t *testing.T) { - // Build a registry with no toolsets enabled (dynamic mode) - reg, err := NewInventory(translations.NullTranslationHelper). - WithToolsets([]string{}). - Build() - require.NoError(t, err) - - // Create a mock server - server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) - - // Create dynamic tool dependencies - deps := DynamicToolDependencies{ - Server: server, - Inventory: reg, - ToolDeps: NewBaseDeps(nil, nil, nil, nil, translations.NullTranslationHelper, FeatureFlags{}, 0, nil), - T: translations.NullTranslationHelper, - } - - // Verify repos is not enabled initially - assert.False(t, reg.IsToolsetEnabled(inventory.ToolsetID("repos"))) - - // Get the enable_toolset tool - tool := EnableToolset(reg) - handler := tool.Handler(deps) - - // Enable the repos toolset - result, err := handler(context.Background(), createDynamicRequest(map[string]any{ - "toolset": "repos", - })) - require.NoError(t, err) - require.NotNil(t, result) - require.Len(t, result.Content, 1) - - // Verify the toolset is now enabled - assert.True(t, reg.IsToolsetEnabled(inventory.ToolsetID("repos")), "repos should be enabled after enable_toolset") - - // Verify the success message - textContent := result.Content[0].(*mcp.TextContent) - assert.Contains(t, textContent.Text, "enabled") - - // Try enabling again - should say already enabled - result2, err := handler(context.Background(), createDynamicRequest(map[string]any{ - "toolset": "repos", - })) - require.NoError(t, err) - textContent2 := result2.Content[0].(*mcp.TextContent) - assert.Contains(t, textContent2.Text, "already enabled") -} - -func TestDynamicTools_EnableToolset_InvalidToolset(t *testing.T) { - // Build a registry with no toolsets enabled (dynamic mode) - reg, err := NewInventory(translations.NullTranslationHelper). - WithToolsets([]string{}). - Build() - require.NoError(t, err) - - // Create a mock server - server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) - - // Create dynamic tool dependencies - deps := DynamicToolDependencies{ - Server: server, - Inventory: reg, - ToolDeps: nil, - T: translations.NullTranslationHelper, - } - - // Get the enable_toolset tool - tool := EnableToolset(reg) - handler := tool.Handler(deps) - - // Try to enable a non-existent toolset - result, err := handler(context.Background(), createDynamicRequest(map[string]any{ - "toolset": "nonexistent", - })) - require.NoError(t, err) - require.NotNil(t, result) - - // Should be an error result - textContent := result.Content[0].(*mcp.TextContent) - assert.Contains(t, textContent.Text, "not found") -} - -func TestDynamicTools_ToolsetsEnum(t *testing.T) { - // Build a registry - reg, err := NewInventory(translations.NullTranslationHelper).Build() - require.NoError(t, err) - - // Get tools to verify they have proper enum values - tools := DynamicTools(reg) - - // Find enable_toolset and get_toolset_tools - for _, tool := range tools { - if tool.Tool.Name == "enable_toolset" || tool.Tool.Name == "get_toolset_tools" { - // Verify the toolset property has an enum - schema := tool.Tool.InputSchema.(*jsonschema.Schema) - toolsetProp := schema.Properties["toolset"] - require.NotNil(t, toolsetProp, "toolset property should exist") - assert.NotEmpty(t, toolsetProp.Enum, "toolset property should have enum values") - - // Verify repos is in the enum - var foundRepos bool - for _, v := range toolsetProp.Enum { - if v == inventory.ToolsetID("repos") { - foundRepos = true - break - } - } - assert.True(t, foundRepos, "repos should be in toolset enum for %s", tool.Tool.Name) - } - } -} diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index fd06a659be..0f77f6c872 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -1,7 +1,76 @@ package github +import "slices" + +// MCPAppsFeatureFlag is the feature flag name for MCP Apps (interactive UI forms). +const MCPAppsFeatureFlag = "remote_mcp_ui_apps" + +// FeatureFlagCSVOutput is the feature flag name for CSV output on list tools. +const FeatureFlagCSVOutput = "csv_output" + +// FeatureFlagIFCLabels is the feature flag name for IFC security labels in tool results. +const FeatureFlagIFCLabels = "ifc_labels" + +// FeatureFlagIssueFields is the feature flag name for Issues 2.0 custom field +// support: the list_issue_fields tool, the field_filters input on list_issues, +// and field_values enrichment in list_issues / search_issues output. +const FeatureFlagIssueFields = "remote_mcp_issue_fields" + +// AllowedFeatureFlags is the allowlist of feature flags that can be enabled +// by users via --features CLI flag or X-MCP-Features HTTP header. +// Only flags in this list are accepted; unknown flags are silently ignored. +// This is the single source of truth for which flags are user-controllable. +var AllowedFeatureFlags = []string{ + MCPAppsFeatureFlag, + FeatureFlagCSVOutput, + FeatureFlagIFCLabels, + FeatureFlagIssueFields, + FeatureFlagIssuesGranular, + FeatureFlagPullRequestsGranular, +} + +// InsidersFeatureFlags is the list of feature flags that insiders mode enables. +// When insiders mode is active, all flags in this list are treated as enabled. +// This is the single source of truth for what "insiders" means in terms of +// feature flag expansion. +var InsidersFeatureFlags = []string{ + MCPAppsFeatureFlag, + FeatureFlagCSVOutput, + FeatureFlagIFCLabels, + FeatureFlagIssueFields, +} + // FeatureFlags defines runtime feature toggles that adjust tool behavior. type FeatureFlags struct { LockdownMode bool - InsidersMode bool +} + +// ResolveFeatureFlags computes the effective set of enabled feature flags by: +// 1. Taking the user-supplied flags (from --features or X-MCP-Features) and +// keeping only those present in AllowedFeatureFlags. Unknown or unsafe +// flags from request input are silently dropped here. +// 2. If insiders mode is on, unioning in every flag from InsidersFeatureFlags. +// Insiders is a server-controlled meta switch, so its expansion is NOT +// re-validated against AllowedFeatureFlags. +// +// AllowedFeatureFlags and InsidersFeatureFlags are independent sets: +// - A flag in AllowedFeatureFlags but not InsidersFeatureFlags is a regular +// opt-in flag that insiders mode does not turn on automatically. +// - A flag in InsidersFeatureFlags but not AllowedFeatureFlags is reachable +// only through insiders mode and cannot be enabled by user input. +// +// Returns a set (map) for O(1) lookup by the feature checker. +func ResolveFeatureFlags(enabledFeatures []string, insidersMode bool) map[string]bool { + effective := make(map[string]bool) + for _, f := range enabledFeatures { + if slices.Contains(AllowedFeatureFlags, f) { + effective[f] = true + } + } + if insidersMode { + for _, f := range InsidersFeatureFlags { + effective[f] = true + } + } + return effective } diff --git a/pkg/github/feature_flags_test.go b/pkg/github/feature_flags_test.go index 2f0a435c95..3f9d211953 100644 --- a/pkg/github/feature_flags_test.go +++ b/pkg/github/feature_flags_test.go @@ -18,10 +18,14 @@ import ( // 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 +func featureCheckerFor(enabledFlags ...string) func(context.Context, string) (bool, error) { + enabled := make(map[string]bool, len(enabledFlags)) + for _, flag := range enabledFlags { + enabled[flag] = true + } + return func(_ context.Context, flagName string) (bool, error) { + return enabled[flagName], nil + } } // HelloWorld returns a simple greeting tool that demonstrates feature flag conditional behavior. @@ -45,9 +49,6 @@ func HelloWorldTool(t translations.TranslationHelperFunc) inventory.ServerTool { if deps.IsFeatureEnabled(ctx, RemoteMCPEnthusiasticGreeting) { greeting += " Welcome to the future of MCP! 🎉" } - if deps.GetFlags(ctx).InsidersMode { - greeting += " Experimental features are enabled! 🚀" - } // Build response response := map[string]any{ @@ -89,12 +90,9 @@ func TestHelloWorld_ConditionalBehavior_Featureflag(t *testing.T) { 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 + var enabledFlags []string + if tt.featureFlagEnabled { + enabledFlags = append(enabledFlags, RemoteMCPEnthusiasticGreeting) } // Create deps with the checker @@ -103,7 +101,8 @@ func TestHelloWorld_ConditionalBehavior_Featureflag(t *testing.T) { translations.NullTranslationHelper, FeatureFlags{}, 0, - checker, + featureCheckerFor(enabledFlags...), + stubExporters(), ) // Get the tool and its handler @@ -135,64 +134,85 @@ func TestHelloWorld_ConditionalBehavior_Featureflag(t *testing.T) { } } -func TestHelloWorld_ConditionalBehavior_Config(t *testing.T) { +func TestResolveFeatureFlags(t *testing.T) { t.Parallel() tests := []struct { - name string - insidersMode bool - expectedGreeting string + name string + enabledFeatures []string + insidersMode bool + expectedFlags []string + unexpectedFlags []string }{ { - name: "Experimental disabled - default greeting", - insidersMode: false, - expectedGreeting: "Hello, world!", + name: "no features, no insiders", + enabledFeatures: nil, + expectedFlags: nil, + unexpectedFlags: []string{MCPAppsFeatureFlag}, + }, + { + name: "explicit feature enabled", + enabledFeatures: []string{MCPAppsFeatureFlag}, + expectedFlags: []string{MCPAppsFeatureFlag}, + }, + { + name: "insiders mode enables insiders flags", + enabledFeatures: nil, + insidersMode: true, + expectedFlags: InsidersFeatureFlags, }, { - name: "Experimental enabled - experimental greeting", - insidersMode: true, - expectedGreeting: "Hello, world! Experimental features are enabled! 🚀", + name: "insiders mode enables internal-only flags", + enabledFeatures: nil, + insidersMode: true, + expectedFlags: []string{FeatureFlagIFCLabels}, + }, + { + name: "ifc_labels can be directly enabled", + enabledFeatures: []string{FeatureFlagIFCLabels}, + expectedFlags: []string{FeatureFlagIFCLabels}, + }, + { + name: "unknown flags are filtered out", + enabledFeatures: []string{"unknown_flag", "another_unknown"}, + unexpectedFlags: []string{"unknown_flag", "another_unknown"}, + }, + { + name: "mix of known and unknown flags", + enabledFeatures: []string{MCPAppsFeatureFlag, "unknown_flag"}, + expectedFlags: []string{MCPAppsFeatureFlag}, + unexpectedFlags: []string{"unknown_flag"}, + }, + { + name: "user-only flags can be enabled but are not turned on by insiders", + enabledFeatures: []string{FeatureFlagIssuesGranular}, + insidersMode: false, + expectedFlags: []string{FeatureFlagIssuesGranular}, + }, + { + name: "insiders does not enable user-only allowed flags", + enabledFeatures: nil, + insidersMode: true, + unexpectedFlags: []string{FeatureFlagIssuesGranular, FeatureFlagPullRequestsGranular}, + }, + { + name: "explicit plus insiders deduplicates", + enabledFeatures: []string{MCPAppsFeatureFlag}, + insidersMode: true, + expectedFlags: InsidersFeatureFlags, }, } 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"]) + result := ResolveFeatureFlags(tt.enabledFeatures, tt.insidersMode) + for _, flag := range tt.expectedFlags { + assert.True(t, result[flag], "expected flag %q to be enabled", flag) + } + for _, flag := range tt.unexpectedFlags { + assert.False(t, result[flag], "expected flag %q to not be enabled", flag) + } }) } } diff --git a/pkg/github/gists.go b/pkg/github/gists.go index a0bc1b0855..de577af04d 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -12,7 +12,7 @@ import ( "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/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/gists_test.go b/pkg/github/gists_test.go index 74cd45d276..342cd0c8f5 100644 --- a/pkg/github/gists_test.go +++ b/pkg/github/gists_test.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -141,7 +141,7 @@ func Test_ListGists(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -252,7 +252,7 @@ func Test_GetGist(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -392,7 +392,7 @@ func Test_CreateGist(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -545,7 +545,7 @@ func Test_UpdateGist(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/git.go b/pkg/github/git.go index 33a1f94efa..515d8b65f8 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -11,7 +11,7 @@ import ( "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/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/git_test.go b/pkg/github/git_test.go index cef65c9ef4..1ad7147507 100644 --- a/pkg/github/git_test.go +++ b/pkg/github/git_test.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -125,7 +125,7 @@ func Test_GetRepositoryTree(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go new file mode 100644 index 0000000000..eb688a0b9f --- /dev/null +++ b/pkg/github/granular_tools_test.go @@ -0,0 +1,1805 @@ +package github + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + gogithub "github.com/google/go-github/v87/github" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func granularToolsForToolset(toolsetID inventory.ToolsetID, featureFlag string) []inventory.ServerTool { + var result []inventory.ServerTool + for _, tool := range AllTools(translations.NullTranslationHelper) { + if tool.Toolset.ID == toolsetID && tool.FeatureFlagEnable == featureFlag { + result = append(result, tool) + } + } + return result +} + +func TestGranularToolSnaps(t *testing.T) { + // Test toolsnaps for all granular tools + toolConstructors := []func(translations.TranslationHelperFunc) inventory.ServerTool{ + GranularCreateIssue, + GranularUpdateIssueTitle, + GranularUpdateIssueBody, + GranularUpdateIssueAssignees, + GranularUpdateIssueLabels, + GranularUpdateIssueMilestone, + GranularUpdateIssueType, + GranularUpdateIssueState, + GranularAddSubIssue, + GranularRemoveSubIssue, + GranularReprioritizeSubIssue, + GranularSetIssueFields, + GranularUpdatePullRequestTitle, + GranularUpdatePullRequestBody, + GranularUpdatePullRequestState, + GranularUpdatePullRequestDraftState, + GranularRequestPullRequestReviewers, + GranularCreatePullRequestReview, + GranularSubmitPendingPullRequestReview, + GranularDeletePendingPullRequestReview, + GranularAddPullRequestReviewComment, + GranularResolveReviewThread, + GranularUnresolveReviewThread, + } + + for _, constructor := range toolConstructors { + serverTool := constructor(translations.NullTranslationHelper) + t.Run(serverTool.Tool.Name, func(t *testing.T) { + require.NoError(t, toolsnaps.Test(serverTool.Tool.Name, serverTool.Tool)) + }) + } +} + +func TestIssuesGranularToolset(t *testing.T) { + t.Run("toolset contains expected granular tools", func(t *testing.T) { + tools := granularToolsForToolset(ToolsetMetadataIssues.ID, FeatureFlagIssuesGranular) + + toolNames := make([]string, 0, len(tools)) + for _, tool := range tools { + toolNames = append(toolNames, tool.Tool.Name) + } + + expected := []string{ + "create_issue", + "update_issue_title", + "update_issue_body", + "update_issue_assignees", + "update_issue_labels", + "update_issue_milestone", + "update_issue_type", + "update_issue_state", + "add_sub_issue", + "remove_sub_issue", + "reprioritize_sub_issue", + "set_issue_fields", + } + for _, name := range expected { + assert.Contains(t, toolNames, name) + } + assert.Len(t, tools, len(expected)) + }) + + t.Run("all granular tools have correct feature flag", func(t *testing.T) { + for _, tool := range granularToolsForToolset(ToolsetMetadataIssues.ID, FeatureFlagIssuesGranular) { + assert.Equal(t, FeatureFlagIssuesGranular, tool.FeatureFlagEnable, "tool %s", tool.Tool.Name) + } + }) +} + +func TestPullRequestsGranularToolset(t *testing.T) { + t.Run("toolset contains expected granular tools", func(t *testing.T) { + tools := granularToolsForToolset(ToolsetMetadataPullRequests.ID, FeatureFlagPullRequestsGranular) + + toolNames := make([]string, 0, len(tools)) + for _, tool := range tools { + toolNames = append(toolNames, tool.Tool.Name) + } + + expected := []string{ + "update_pull_request_title", + "update_pull_request_body", + "update_pull_request_state", + "update_pull_request_draft_state", + "request_pull_request_reviewers", + "create_pull_request_review", + "submit_pending_pull_request_review", + "delete_pending_pull_request_review", + "add_pull_request_review_comment", + "resolve_review_thread", + "unresolve_review_thread", + } + for _, name := range expected { + assert.Contains(t, toolNames, name) + } + assert.Len(t, tools, len(expected)) + }) + + t.Run("all granular tools have correct feature flag", func(t *testing.T) { + for _, tool := range granularToolsForToolset(ToolsetMetadataPullRequests.ID, FeatureFlagPullRequestsGranular) { + assert.Equal(t, FeatureFlagPullRequestsGranular, tool.FeatureFlagEnable, "tool %s", tool.Tool.Name) + } + }) +} + +// --- Issue granular tool handler tests --- + +func TestGranularCreateIssue(t *testing.T) { + mockIssue := &gogithub.Issue{ + Number: gogithub.Ptr(1), + Title: gogithub.Ptr("Test Issue"), + Body: gogithub.Ptr("Test body"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectedErrMsg string + }{ + { + name: "successful creation", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{ + "title": "Test Issue", + "body": "Test body", + }).andThen(mockResponse(t, http.StatusCreated, mockIssue)), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test Issue", + "body": "Test body", + }, + }, + { + name: "missing required parameter", + mockedClient: MockHTTPClientWithHandlers(nil), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectedErrMsg: "missing required parameter: title", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + serverTool := GranularCreateIssue(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectedErrMsg != "" { + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + assert.False(t, result.IsError) + }) + } +} + +func TestGranularUpdateIssueTitle(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, &gogithub.Issue{ + Number: gogithub.Ptr(42), + Title: gogithub.Ptr("New Title"), + }), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueTitle(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "title": "New Title", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestGranularUpdateIssueBody(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ + "body": "Updated body", + }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{ + Number: gogithub.Ptr(1), + Body: gogithub.Ptr("Updated body"), + })), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueBody(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "body": "Updated body", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestGranularUpdateIssueAssignees(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ + "assignees": []any{"user1", "user2"}, + }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueAssignees(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "assignees": []string{"user1", "user2"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestGranularUpdateIssueLabels(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "labels as plain strings", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{"bug", "enhancement"}, + }, + expectedReq: map[string]any{ + "labels": []any{"bug", "enhancement"}, + }, + }, + { + name: "label objects without rationale serialize as strings", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "bug"}, + "enhancement", + }, + }, + expectedReq: map[string]any{ + "labels": []any{"bug", "enhancement"}, + }, + }, + { + name: "mixed strings and label objects with rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + "triage", + map[string]any{"name": "bug", "rationale": " Reports a crash when saving "}, + map[string]any{"name": "frontend", "rationale": "Mentions the UI button"}, + }, + }, + expectedReq: map[string]any{ + "labels": []any{ + "triage", + map[string]any{"name": "bug", "rationale": "Reports a crash when saving"}, + map[string]any{"name": "frontend", "rationale": "Mentions the UI button"}, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueLabels(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + +func TestGranularUpdateIssueLabelsSuggest(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "single label suggested without rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "bug", "is_suggestion": true}, + }, + }, + expectedReq: map[string]any{ + "labels": []any{ + map[string]any{"name": "bug", "suggest": true}, + }, + }, + }, + { + name: "suggested label with rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "frontend", "rationale": "Mentions the UI button", "is_suggestion": true}, + }, + }, + expectedReq: map[string]any{ + "labels": []any{ + map[string]any{"name": "frontend", "rationale": "Mentions the UI button", "suggest": true}, + }, + }, + }, + { + name: "mix of plain, applied-with-rationale, and suggested labels", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + "triage", + map[string]any{"name": "bug", "rationale": "Reports a crash when saving"}, + map[string]any{"name": "needs-design", "is_suggestion": true}, + }, + }, + expectedReq: map[string]any{ + "labels": []any{ + "triage", + map[string]any{"name": "bug", "rationale": "Reports a crash when saving"}, + map[string]any{"name": "needs-design", "suggest": true}, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueLabels(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + +func TestGranularUpdateIssueLabelsInvalidRationale(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedErrText string + }{ + { + name: "rationale too long", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "bug", "rationale": strings.Repeat("a", 281)}, + }, + }, + expectedErrText: "label rationale must be 280 characters or less", + }, + { + name: "label object missing name", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"rationale": "no name provided"}, + }, + }, + expectedErrText: "each label object must have a 'name' string", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))} + serverTool := GranularUpdateIssueLabels(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrText) + }) + } +} + +func TestGranularUpdateIssueLabelsConfidence(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "label with confidence triggers object form", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "bug", "confidence": "high"}, + }, + }, + expectedReq: map[string]any{ + "labels": []any{ + map[string]any{"name": "bug", "confidence": "high"}, + }, + }, + }, + { + name: "label with confidence and rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "medium"}, + }, + }, + expectedReq: map[string]any{ + "labels": []any{ + map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "medium"}, + }, + }, + }, + { + name: "invalid confidence value", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "bug", "confidence": "very_high"}, + }, + }, + expectedReq: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.expectedReq == nil { + // Error case + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))} + serverTool := GranularUpdateIssueLabels(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, "confidence must be one of: low, medium, high") + return + } + + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueLabels(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + +func TestGranularUpdateIssueMilestone(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ + "milestone": float64(5), + }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueMilestone(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "milestone": float64(5), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestGranularUpdateIssueType(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "type only", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "bug", + }, + expectedReq: map[string]any{ + "type": "bug", + }, + }, + { + name: "type with rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "feature", + "rationale": " This issue requests a new capability ", + }, + expectedReq: map[string]any{ + "type": map[string]any{ + "value": "feature", + "rationale": "This issue requests a new capability", + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + +func TestGranularUpdateIssueTypeSuggest(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "suggest without rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "bug", + "is_suggestion": true, + }, + expectedReq: map[string]any{ + "type": map[string]any{ + "value": "bug", + "suggest": true, + }, + }, + }, + { + name: "suggest with rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "feature", + "rationale": " Asks for dark mode support ", + "is_suggestion": true, + }, + expectedReq: map[string]any{ + "type": map[string]any{ + "value": "feature", + "rationale": "Asks for dark mode support", + "suggest": true, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + +func TestGranularUpdateIssueTypeInvalidRationale(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedErrText string + }{ + { + name: "rationale wrong type", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "feature", + "rationale": float64(123), + }, + expectedErrText: "parameter rationale is not of type string, is float64", + }, + { + name: "rationale too long", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "feature", + "rationale": strings.Repeat("a", 281), + }, + expectedErrText: "parameter rationale must be 280 characters or less", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))} + serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrText) + }) + } +} + +func TestGranularUpdateIssueTypeConfidence(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "type with confidence only", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "bug", + "confidence": "high", + }, + expectedReq: map[string]any{ + "type": map[string]any{ + "value": "bug", + "confidence": "high", + }, + }, + }, + { + name: "type with confidence and rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "feature", + "rationale": "Asks for dark mode support", + "confidence": "medium", + }, + expectedReq: map[string]any{ + "type": map[string]any{ + "value": "feature", + "rationale": "Asks for dark mode support", + "confidence": "medium", + }, + }, + }, + { + name: "type with low confidence triggers object form", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "bug", + "confidence": "low", + }, + expectedReq: map[string]any{ + "type": map[string]any{ + "value": "bug", + "confidence": "low", + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + +func TestGranularUpdateIssueTypeInvalidConfidence(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedErrText string + }{ + { + name: "invalid confidence value", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "bug", + "confidence": "very_high", + }, + expectedErrText: "confidence must be one of: low, medium, high", + }, + { + name: "confidence wrong type", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "bug", + "confidence": float64(85), + }, + expectedErrText: "parameter confidence is not of type string", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))} + serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrText) + }) + } +} + +func TestGranularUpdateIssueState(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "close with reason", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "state": "closed", + "state_reason": "completed", + }, + expectedReq: map[string]any{ + "state": "closed", + "state_reason": "completed", + }, + }, + { + name: "reopen without reason", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "state": "open", + }, + expectedReq: map[string]any{ + "state": "open", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{ + Number: gogithub.Ptr(1), + State: gogithub.Ptr(tc.requestArgs["state"].(string)), + })), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueState(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + +// --- Pull request granular tool handler tests --- + +func TestGranularUpdatePullRequestTitle(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ + "title": "New PR Title", + }).andThen(mockResponse(t, http.StatusOK, &gogithub.PullRequest{ + Number: gogithub.Ptr(1), + Title: gogithub.Ptr("New PR Title"), + })), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdatePullRequestTitle(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(1), + "title": "New PR Title", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestGranularUpdatePullRequestBody(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ + "body": "Updated description", + }).andThen(mockResponse(t, http.StatusOK, &gogithub.PullRequest{ + Number: gogithub.Ptr(1), + Body: gogithub.Ptr("Updated description"), + })), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdatePullRequestBody(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(1), + "body": "Updated description", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestGranularUpdatePullRequestState(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ + "state": "closed", + }).andThen(mockResponse(t, http.StatusOK, &gogithub.PullRequest{ + Number: gogithub.Ptr(1), + State: gogithub.Ptr("closed"), + })), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdatePullRequestState(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(1), + "state": "closed", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestGranularRequestPullRequestReviewers(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ + "reviewers": []any{"user1"}, + "team_reviewers": []any{"team1"}, + }).andThen(mockResponse(t, http.StatusOK, &gogithub.PullRequest{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularRequestPullRequestReviewers(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(1), + "reviewers": []string{"user1", "owner/team1"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestGranularCreatePullRequestReview(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(1), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_123", + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + AddPullRequestReview struct { + PullRequestReview struct { + ID githubv4.ID + } + } `graphql:"addPullRequestReview(input: $input)"` + }{}, + githubv4.AddPullRequestReviewInput{ + PullRequestID: githubv4.ID("PR_123"), + Body: githubv4.NewString("LGTM"), + Event: githubv4mock.Ptr(githubv4.PullRequestReviewEventApprove), + }, + nil, + githubv4mock.DataResponse(map[string]any{}), + ), + ) + gqlClient := githubv4.NewClient(mockedClient) + deps := BaseDeps{GQLClient: gqlClient} + serverTool := GranularCreatePullRequestReview(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(1), + "body": "LGTM", + "event": "APPROVE", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestGranularUpdatePullRequestDraftState(t *testing.T) { + tests := []struct { + name string + draft bool + }{ + {name: "convert to draft", draft: true}, + {name: "mark ready for review", draft: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var matchers []githubv4mock.Matcher + + matchers = append(matchers, githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + } `graphql:"pullRequest(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(1), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{"id": "PR_123"}, + }, + }), + )) + + if tc.draft { + matchers = append(matchers, githubv4mock.NewMutationMatcher( + struct { + ConvertPullRequestToDraft struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } + } `graphql:"convertPullRequestToDraft(input: $input)"` + }{}, + githubv4.ConvertPullRequestToDraftInput{PullRequestID: githubv4.ID("PR_123")}, + nil, + githubv4mock.DataResponse(map[string]any{ + "convertPullRequestToDraft": map[string]any{ + "pullRequest": map[string]any{"id": "PR_123", "isDraft": true}, + }, + }), + )) + } else { + matchers = append(matchers, githubv4mock.NewMutationMatcher( + struct { + MarkPullRequestReadyForReview struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } + } `graphql:"markPullRequestReadyForReview(input: $input)"` + }{}, + githubv4.MarkPullRequestReadyForReviewInput{PullRequestID: githubv4.ID("PR_123")}, + nil, + githubv4mock.DataResponse(map[string]any{ + "markPullRequestReadyForReview": map[string]any{ + "pullRequest": map[string]any{"id": "PR_123", "isDraft": false}, + }, + }), + )) + } + + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...)) + deps := BaseDeps{GQLClient: gqlClient} + serverTool := GranularUpdatePullRequestDraftState(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(1), + "draft": tc.draft, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + +func TestGranularAddPullRequestReviewComment(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Viewer struct { + Login githubv4.String + } + }{}, + nil, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{"login": "testuser"}, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + Reviews struct { + Nodes []struct { + ID githubv4.ID + State githubv4.PullRequestReviewState + URL githubv4.URI + } + } `graphql:"reviews(first: 1, author: $author)"` + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "author": githubv4.String("testuser"), + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "prNum": githubv4.Int(1), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "reviews": map[string]any{ + "nodes": []map[string]any{ + {"id": "PRR_123", "state": "PENDING", "url": "https://github.com/owner/repo/pull/1#pullrequestreview-123"}, + }, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + AddPullRequestReviewThread struct { + Thread struct { + ID githubv4.ID + } + } `graphql:"addPullRequestReviewThread(input: $input)"` + }{}, + githubv4.AddPullRequestReviewThreadInput{ + Path: githubv4.String("src/main.go"), + Body: githubv4.String("This needs a fix"), + SubjectType: githubv4mock.Ptr(githubv4.PullRequestReviewThreadSubjectTypeLine), + Line: githubv4mock.Ptr(githubv4.Int(42)), + Side: githubv4mock.Ptr(githubv4.DiffSideRight), + PullRequestReviewID: githubv4mock.Ptr(githubv4.ID("PRR_123")), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "addPullRequestReviewThread": map[string]any{ + "thread": map[string]any{"id": "PRRT_456"}, + }, + }), + ), + ) + gqlClient := githubv4.NewClient(mockedClient) + deps := BaseDeps{GQLClient: gqlClient} + serverTool := GranularAddPullRequestReviewComment(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(1), + "path": "src/main.go", + "body": "This needs a fix", + "subjectType": "LINE", + "line": float64(42), + "side": "RIGHT", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestGranularResolveReviewThread(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + ResolveReviewThread struct { + Thread struct { + ID githubv4.ID + IsResolved githubv4.Boolean + } + } `graphql:"resolveReviewThread(input: $input)"` + }{}, + githubv4.ResolveReviewThreadInput{ + ThreadID: githubv4.ID("PRRT_123"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "resolveReviewThread": map[string]any{ + "thread": map[string]any{"id": "PRRT_123", "isResolved": true}, + }, + }), + ), + ) + gqlClient := githubv4.NewClient(mockedClient) + deps := BaseDeps{GQLClient: gqlClient} + serverTool := GranularResolveReviewThread(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "threadID": "PRRT_123", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestGranularUnresolveReviewThread(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UnresolveReviewThread struct { + Thread struct { + ID githubv4.ID + IsResolved githubv4.Boolean + } + } `graphql:"unresolveReviewThread(input: $input)"` + }{}, + githubv4.UnresolveReviewThreadInput{ + ThreadID: githubv4.ID("PRRT_123"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "unresolveReviewThread": map[string]any{ + "thread": map[string]any{"id": "PRRT_123", "isResolved": false}, + }, + }), + ), + ) + gqlClient := githubv4.NewClient(mockedClient) + deps := BaseDeps{GQLClient: gqlClient} + serverTool := GranularUnresolveReviewThread(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "threadID": "PRRT_123", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestGranularSetIssueFields(t *testing.T) { + t.Run("successful set with text value", func(t *testing.T) { + matchers := []githubv4mock.Matcher{ + // Mock the issue ID 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("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(5), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{"id": "ISSUE_123"}, + }, + }), + ), + // Mock the setIssueFieldValue mutation + githubv4mock.NewMutationMatcher( + struct { + SetIssueFieldValue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + IssueFieldValues []struct { + TextValue struct { + Value string + } `graphql:"... on IssueFieldTextValue"` + SingleSelectValue struct { + Name string + } `graphql:"... on IssueFieldSingleSelectValue"` + DateValue struct { + Value string + } `graphql:"... on IssueFieldDateValue"` + NumberValue struct { + Value float64 + } `graphql:"... on IssueFieldNumberValue"` + } + } `graphql:"setIssueFieldValue(input: $input)"` + }{}, + SetIssueFieldValueInput{ + IssueID: githubv4.ID("ISSUE_123"), + IssueFields: []IssueFieldCreateOrUpdateInput{ + { + FieldID: githubv4.ID("FIELD_1"), + TextValue: githubv4.NewString(githubv4.String("hello")), + }, + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "setIssueFieldValue": map[string]any{ + "issue": map[string]any{ + "id": "ISSUE_123", + "number": 5, + "url": "https://github.com/owner/repo/issues/5", + }, + }, + }), + ), + } + + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...)) + deps := BaseDeps{GQLClient: gqlClient} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{"field_id": "FIELD_1", "text_value": "hello"}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("missing required parameter fields", func(t *testing.T) { + deps := BaseDeps{} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: fields") + }) + + t.Run("empty fields array", func(t *testing.T) { + deps := BaseDeps{} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "fields array must not be empty") + }) + + t.Run("field missing value", func(t *testing.T) { + deps := BaseDeps{} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{"field_id": "FIELD_1"}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "each field must have a value") + }) + + t.Run("multiple value keys returns error", func(t *testing.T) { + deps := BaseDeps{} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{"field_id": "FIELD_1", "text_value": "hello", "number_value": float64(42)}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "each field must have exactly one value") + }) + + t.Run("value key with delete returns error", func(t *testing.T) { + deps := BaseDeps{} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{"field_id": "FIELD_1", "text_value": "hello", "delete": true}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "each field must have exactly one value") + }) + + t.Run("successful set with text value and rationale", func(t *testing.T) { + matchers := []githubv4mock.Matcher{ + 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("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(5), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{"id": "ISSUE_123"}, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + SetIssueFieldValue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + IssueFieldValues []struct { + TextValue struct { + Value string + } `graphql:"... on IssueFieldTextValue"` + SingleSelectValue struct { + Name string + } `graphql:"... on IssueFieldSingleSelectValue"` + DateValue struct { + Value string + } `graphql:"... on IssueFieldDateValue"` + NumberValue struct { + Value float64 + } `graphql:"... on IssueFieldNumberValue"` + } + } `graphql:"setIssueFieldValue(input: $input)"` + }{}, + SetIssueFieldValueInput{ + IssueID: githubv4.ID("ISSUE_123"), + IssueFields: []IssueFieldCreateOrUpdateInput{ + { + FieldID: githubv4.ID("FIELD_1"), + TextValue: githubv4.NewString(githubv4.String("hello")), + Rationale: githubv4.NewString(githubv4.String("Reflects the reported severity")), + }, + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "setIssueFieldValue": map[string]any{ + "issue": map[string]any{ + "id": "ISSUE_123", + "number": 5, + "url": "https://github.com/owner/repo/issues/5", + }, + }, + }), + ), + } + + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...)) + deps := BaseDeps{GQLClient: gqlClient} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{ + "field_id": "FIELD_1", + "text_value": "hello", + "rationale": " Reflects the reported severity ", + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("rationale too long returns error", func(t *testing.T) { + deps := BaseDeps{} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{ + "field_id": "FIELD_1", + "text_value": "hello", + "rationale": strings.Repeat("a", 281), + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "field rationale must be 280 characters or less") + }) + + t.Run("successful set with confidence", func(t *testing.T) { + confidence := "high" + matchers := []githubv4mock.Matcher{ + 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("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(5), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{"id": "ISSUE_123"}, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + SetIssueFieldValue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + IssueFieldValues []struct { + TextValue struct { + Value string + } `graphql:"... on IssueFieldTextValue"` + SingleSelectValue struct { + Name string + } `graphql:"... on IssueFieldSingleSelectValue"` + DateValue struct { + Value string + } `graphql:"... on IssueFieldDateValue"` + NumberValue struct { + Value float64 + } `graphql:"... on IssueFieldNumberValue"` + } + } `graphql:"setIssueFieldValue(input: $input)"` + }{}, + SetIssueFieldValueInput{ + IssueID: githubv4.ID("ISSUE_123"), + IssueFields: []IssueFieldCreateOrUpdateInput{ + { + FieldID: githubv4.ID("FIELD_1"), + TextValue: githubv4.NewString(githubv4.String("hello")), + Confidence: &confidence, + }, + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "setIssueFieldValue": map[string]any{ + "issue": map[string]any{ + "id": "ISSUE_123", + "number": 5, + "url": "https://github.com/owner/repo/issues/5", + }, + }, + }), + ), + } + + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...)) + deps := BaseDeps{GQLClient: gqlClient} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{ + "field_id": "FIELD_1", + "text_value": "hello", + "confidence": "high", + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("invalid confidence value returns error", func(t *testing.T) { + deps := BaseDeps{} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{ + "field_id": "FIELD_1", + "text_value": "hello", + "confidence": "very_high", + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "confidence must be one of: low, medium, high") + }) + + t.Run("successful set with suggest flag", func(t *testing.T) { + suggestTrue := githubv4.Boolean(true) + matchers := []githubv4mock.Matcher{ + 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("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(5), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{"id": "ISSUE_123"}, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + SetIssueFieldValue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + IssueFieldValues []struct { + TextValue struct { + Value string + } `graphql:"... on IssueFieldTextValue"` + SingleSelectValue struct { + Name string + } `graphql:"... on IssueFieldSingleSelectValue"` + DateValue struct { + Value string + } `graphql:"... on IssueFieldDateValue"` + NumberValue struct { + Value float64 + } `graphql:"... on IssueFieldNumberValue"` + } + } `graphql:"setIssueFieldValue(input: $input)"` + }{}, + SetIssueFieldValueInput{ + IssueID: githubv4.ID("ISSUE_123"), + IssueFields: []IssueFieldCreateOrUpdateInput{ + { + FieldID: githubv4.ID("FIELD_1"), + TextValue: githubv4.NewString(githubv4.String("hello")), + Rationale: githubv4.NewString(githubv4.String("Reflects the reported severity")), + Suggest: &suggestTrue, + }, + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "setIssueFieldValue": map[string]any{ + "issue": map[string]any{ + "id": "ISSUE_123", + "number": 5, + "url": "https://github.com/owner/repo/issues/5", + }, + }, + }), + ), + } + + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...)) + deps := BaseDeps{GQLClient: gqlClient} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{ + "field_id": "FIELD_1", + "text_value": "hello", + "rationale": "Reflects the reported severity", + "is_suggestion": true, + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) +} diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index ff752f5f37..fdac78ce3f 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" testifymock "github.com/stretchr/testify/mock" @@ -39,6 +40,7 @@ const ( GetReposSubscriptionByOwnerByRepo = "GET /repos/{owner}/{repo}/subscription" PutReposSubscriptionByOwnerByRepo = "PUT /repos/{owner}/{repo}/subscription" DeleteReposSubscriptionByOwnerByRepo = "DELETE /repos/{owner}/{repo}/subscription" + ListCollaborators = "GET /repos/{owner}/{repo}/collaborators" // Git endpoints GetReposGitTreesByOwnerByRepoByTree = "GET /repos/{owner}/{repo}/git/trees/{tree}" @@ -138,6 +140,7 @@ const ( GetSearchIssues = "GET /search/issues" GetSearchUsers = "GET /search/users" GetSearchRepositories = "GET /search/repositories" + GetSearchCommits = "GET /search/commits" // 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 @@ -178,6 +181,22 @@ type expectations struct { requestBody any } +// mustNewGHClient creates a new GitHub client for testing. +// If httpClient is nil, a client with no options is created. +// The test fails immediately if client creation fails. +func mustNewGHClient(t *testing.T, httpClient *http.Client) *gogithub.Client { + t.Helper() + var client *gogithub.Client + var err error + if httpClient == nil { + client, err = gogithub.NewClient() + } else { + client, err = gogithub.NewClient(gogithub.WithHTTPClient(httpClient)) + } + require.NoError(t, err) + return client +} + // expect is a helper function to create a partial mock that expects various // request behaviors, such as path, query parameters, and request body. func expect(t *testing.T, e expectations) *partialMock { @@ -219,9 +238,15 @@ func expectRequestBody(t *testing.T, expectedRequestBody any) *partialMock { type partialMock struct { t *testing.T - expectedPath string - expectedQueryParams map[string]string - expectedRequestBody any + expectedPath string + expectedQueryParams map[string]string + expectedRequestBody any + expectedHeaderContains map[string]string +} + +func (p *partialMock) withHeaders(headers map[string]string) *partialMock { + p.expectedHeaderContains = headers + return p } func (p *partialMock) andThen(responseHandler http.HandlerFunc) http.HandlerFunc { @@ -246,6 +271,12 @@ func (p *partialMock) andThen(responseHandler http.HandlerFunc) http.HandlerFunc require.Equal(p.t, p.expectedRequestBody, unmarshaledRequestBody) } + if p.expectedHeaderContains != nil { + for k, v := range p.expectedHeaderContains { + require.Contains(p.t, r.Header.Get(k), v, "expected header %q to contain %q", k, v) + } + } + responseHandler(w, r) } } diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go new file mode 100644 index 0000000000..1eabbc02f3 --- /dev/null +++ b/pkg/github/issue_fields.go @@ -0,0 +1,264 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + 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" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +// IssueField represents a repository issue field definition. +type IssueField struct { + ID string `json:"id"` + DatabaseID int64 `json:"full_database_id,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DataType string `json:"data_type"` + Visibility string `json:"visibility"` + Options []IssueSingleSelectFieldOption `json:"options,omitempty"` +} + +// IssueSingleSelectFieldOption represents an option for a single_select issue field. +type IssueSingleSelectFieldOption struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Color string `json:"color"` + Priority *int `json:"priority,omitempty"` +} + +// issueFieldNode is the GraphQL fragment for a single issue field in the IssueFields union. +// Only the fragment matching __typename is populated; read from the matching fragment. +// fullDatabaseId (BigInt scalar, returned as string) is fetched on each concrete type because +// shurcooL/githubv4 does not support interface fragments at the top level of a union. +type issueFieldNode struct { + TypeName githubv4.String `graphql:"__typename"` + IssueFieldText struct { + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + } `graphql:"... on IssueFieldText"` + IssueFieldNumber struct { + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + } `graphql:"... on IssueFieldNumber"` + IssueFieldDate struct { + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + } `graphql:"... on IssueFieldDate"` + IssueFieldSingleSelect struct { + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + Options []struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + Color githubv4.String + Priority *int + } + } `graphql:"... on IssueFieldSingleSelect"` +} + +// issueFieldsRepoQuery is the GraphQL query for listing issue fields on a repository. +type issueFieldsRepoQuery struct { + Repository struct { + IssueFields struct { + Nodes []issueFieldNode + } `graphql:"issueFields(first: 100)"` + } `graphql:"repository(owner: $owner, name: $name)"` +} + +// issueFieldsOrgQuery is the GraphQL query for listing issue fields on an organization. +type issueFieldsOrgQuery struct { + Organization struct { + IssueFields struct { + Nodes []issueFieldNode + } `graphql:"issueFields(first: 100)"` + } `graphql:"organization(login: $login)"` +} + +// ListIssueFields creates a tool to list issue field definitions for a repository or organization. +// Gated by FeatureFlagIssueFields: the tool is only registered when the flag is on. +func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "list_issue_fields", + Description: t("TOOL_LIST_ISSUE_FIELDS_DESCRIPTION", "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_ISSUE_FIELDS_USER_TITLE", "List issue fields"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The account owner of the repository or organization. The name is not case sensitive.", + }, + "repo": { + Type: "string", + Description: "The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly.", + }, + }, + Required: []string{"owner"}, + }, + }, + []scopes.Scope{scopes.Repo, 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 { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := OptionalParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + + fields, err := fetchIssueFields(ctx, gqlClient, owner, repo) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil + } + + r, err := json.Marshal(fields) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil + }) + st.FeatureFlagEnable = FeatureFlagIssueFields + return st +} + +// fetchIssueFields returns the issue field definitions for the given owner. +// If repo is provided, fields are scoped to that repository (inherited from its +// organization); otherwise fields are returned directly from the organization. +func fetchIssueFields(ctx context.Context, gqlClient *githubv4.Client, owner, repo string) ([]IssueField, error) { + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") + if repo != "" { + var query issueFieldsRepoQuery + vars := map[string]any{ + "owner": githubv4.String(owner), + "name": githubv4.String(repo), + } + if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { + return nil, err + } + return issueFieldsFromNodes(query.Repository.IssueFields.Nodes), nil + } + + var query issueFieldsOrgQuery + vars := map[string]any{ + "login": githubv4.String(owner), + } + if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { + return nil, err + } + return issueFieldsFromNodes(query.Organization.IssueFields.Nodes), nil +} + +// issueFieldsFromNodes converts GraphQL issue field union nodes into IssueField values. +// Read from the fragment matching __typename; the other fragments are zero-valued. +func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField { + fields := make([]IssueField, 0, len(nodes)) + for _, node := range nodes { + var f IssueField + switch string(node.TypeName) { + case "IssueFieldSingleSelect": + opts := make([]IssueSingleSelectFieldOption, 0, len(node.IssueFieldSingleSelect.Options)) + for _, o := range node.IssueFieldSingleSelect.Options { + opts = append(opts, IssueSingleSelectFieldOption{ + ID: fmt.Sprintf("%v", o.ID), + Name: string(o.Name), + Description: string(o.Description), + Color: string(o.Color), + Priority: o.Priority, + }) + } + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldSingleSelect.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldSingleSelect.FullDatabaseID)), + Name: string(node.IssueFieldSingleSelect.Name), + Description: string(node.IssueFieldSingleSelect.Description), + DataType: string(node.IssueFieldSingleSelect.DataType), + Visibility: string(node.IssueFieldSingleSelect.Visibility), + Options: opts, + } + case "IssueFieldText": + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldText.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldText.FullDatabaseID)), + Name: string(node.IssueFieldText.Name), + Description: string(node.IssueFieldText.Description), + DataType: string(node.IssueFieldText.DataType), + Visibility: string(node.IssueFieldText.Visibility), + } + case "IssueFieldNumber": + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldNumber.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldNumber.FullDatabaseID)), + Name: string(node.IssueFieldNumber.Name), + Description: string(node.IssueFieldNumber.Description), + DataType: string(node.IssueFieldNumber.DataType), + Visibility: string(node.IssueFieldNumber.Visibility), + } + case "IssueFieldDate": + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldDate.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldDate.FullDatabaseID)), + Name: string(node.IssueFieldDate.Name), + Description: string(node.IssueFieldDate.Description), + DataType: string(node.IssueFieldDate.DataType), + Visibility: string(node.IssueFieldDate.Visibility), + } + default: + continue + } + fields = append(fields, f) + } + return fields +} + +// parseFullDatabaseID converts a BigInt scalar string (e.g. "12345") to int64. +// Returns 0 if the string is empty or cannot be parsed. +func parseFullDatabaseID(s string) int64 { + if s == "" { + return 0 + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0 + } + return n +} diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go new file mode 100644 index 0000000000..2c2b26ee2a --- /dev/null +++ b/pkg/github/issue_fields_test.go @@ -0,0 +1,308 @@ +package github + +import ( + "context" + "encoding/json" + "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" + "github.com/google/jsonschema-go/jsonschema" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ListIssueFields(t *testing.T) { + // Verify tool definition + serverTool := ListIssueFields(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_issue_fields", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.True(t, tool.Annotations.ReadOnlyHint) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner"}) + assert.ElementsMatch(t, serverTool.RequiredScopes, []string{"repo", "read:org"}) + assert.ElementsMatch(t, serverTool.AcceptedScopes, []string{"repo", "read:org", "write:org", "admin:org"}) + + queryStruct := issueFieldsRepoQuery{} + defaultVars := map[string]any{ + "owner": githubv4.String("testowner"), + "name": githubv4.String("testrepo"), + } + orgQueryStruct := issueFieldsOrgQuery{} + defaultOrgVars := map[string]any{ + "login": githubv4.String("testowner"), + } + + tests := []struct { + name string + requestArgs map[string]any + mockQueryStruct any + mockVars map[string]any + gqlResponse githubv4mock.GQLResponse + expectError bool + expectedFields []IssueField + expectedErrMsg string + }{ + { + name: "no fields returns empty list", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{}, + }, + }, + }), + expectedFields: []IssueField{}, + }, + { + name: "text field returned", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldText", + "id": "IFT_1", + "fullDatabaseId": "42", + "name": "DRI", + "description": "Directly responsible individual", + "dataType": "TEXT", + "visibility": "ORG_ONLY", + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + { + ID: "IFT_1", + DatabaseID: 42, + Name: "DRI", + Description: "Directly responsible individual", + DataType: "TEXT", + Visibility: "ORG_ONLY", + }, + }, + }, + { + name: "single_select field with options returned", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "id": "IFSS_1", + "fullDatabaseId": "99", + "name": "Priority", + "description": "Level of importance", + "dataType": "SINGLE_SELECT", + "visibility": "ALL", + "options": []any{ + map[string]any{ + "id": "OPT_1", + "name": "High", + "color": "red", + }, + map[string]any{ + "id": "OPT_2", + "name": "Low", + "color": "blue", + }, + }, + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + { + ID: "IFSS_1", + DatabaseID: 99, + Name: "Priority", + Description: "Level of importance", + DataType: "SINGLE_SELECT", + Visibility: "ALL", + Options: []IssueSingleSelectFieldOption{ + {ID: "OPT_1", Name: "High", Color: "red"}, + {ID: "OPT_2", Name: "Low", Color: "blue"}, + }, + }, + }, + }, + { + name: "missing owner parameter", + requestArgs: map[string]any{ + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{}), + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "no repo returns org-level fields", + requestArgs: map[string]any{ + "owner": "testowner", + }, + mockQueryStruct: orgQueryStruct, + mockVars: defaultOrgVars, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldText", + "id": "IFT_1", + "fullDatabaseId": "77", + "name": "DRI", + "dataType": "TEXT", + "visibility": "ORG_ONLY", + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + {ID: "IFT_1", DatabaseID: 77, Name: "DRI", DataType: "TEXT", Visibility: "ORG_ONLY"}, + }, + }, + { + name: "number field returned", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldNumber", + "id": "IFN_1", + "fullDatabaseId": "101", + "name": "Engineering Staffing", + "dataType": "NUMBER", + "visibility": "ORG_ONLY", + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + {ID: "IFN_1", DatabaseID: 101, Name: "Engineering Staffing", DataType: "NUMBER", Visibility: "ORG_ONLY"}, + }, + }, + { + name: "date field returned", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldDate", + "id": "IFD_1", + "fullDatabaseId": "202", + "name": "Target Date", + "dataType": "DATE", + "visibility": "ORG_ONLY", + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + {ID: "IFD_1", DatabaseID: 202, Name: "Target Date", DataType: "DATE", Visibility: "ORG_ONLY"}, + }, + }, + { + name: "graphql error returns failure", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.ErrorResponse("boom"), + expectError: true, + expectedErrMsg: "failed to list issue fields", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + qs := tc.mockQueryStruct + if qs == nil { + qs = queryStruct + } + vars := tc.mockVars + if vars == nil { + vars = defaultVars + } + mockedHTTPClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher(qs, vars, tc.gqlResponse), + ) + gqlClient := githubv4.NewClient(mockedHTTPClient) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + require.NotNil(t, result) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError) + textContent := getTextResult(t, result) + + var returnedFields []IssueField + err = json.Unmarshal([]byte(textContent.Text), &returnedFields) + require.NoError(t, err) + require.Equal(t, len(tc.expectedFields), len(returnedFields)) + for i, expected := range tc.expectedFields { + assert.Equal(t, expected.ID, returnedFields[i].ID) + assert.Equal(t, expected.DatabaseID, returnedFields[i].DatabaseID) + assert.Equal(t, expected.Name, returnedFields[i].Name) + assert.Equal(t, expected.DataType, returnedFields[i].DataType) + assert.Equal(t, expected.Visibility, returnedFields[i].Visibility) + if expected.Options != nil { + require.Equal(t, len(expected.Options), len(returnedFields[i].Options)) + for j, opt := range expected.Options { + assert.Equal(t, opt.Name, returnedFields[i].Options[j].Name) + assert.Equal(t, opt.Color, returnedFields[i].Options[j].Color) + } + } + } + }) + } +} diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 05af64cab4..c9d3eb3dcf 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -6,16 +6,19 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" "time" + ghcontext "github.com/github/github-mcp-server/pkg/context" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/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/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -34,6 +37,15 @@ type CloseIssueInput struct { // Used to extend the functionality of the githubv4 library to support closing issues as duplicates. type IssueClosedStateReason string +// issueWriteFieldInput is a user-friendly issue field input for issue_write. +// Field IDs and option IDs are resolved internally before calling the REST API. +type issueWriteFieldInput struct { + FieldName string + Value any + FieldOptionName string + Delete bool +} + const ( IssueClosedStateReasonCompleted IssueClosedStateReason = "COMPLETED" IssueClosedStateReasonDuplicate IssueClosedStateReason = "DUPLICATE" @@ -102,13 +114,378 @@ func getCloseStateReason(stateReason string) IssueClosedStateReason { } } +// issueFieldWriteMetadataNode queries only the fields needed to resolve a write: the field's +// fullDatabaseId (BigInt scalar, returned as string) plus its name and data type for validation. +// shurcooL/githubv4 cannot use interface-level fragments at union top-level, so we repeat +// fullDatabaseId on each concrete type; all four implement IssueFieldCommon. +type issueFieldWriteMetadataNode struct { + TypeName githubv4.String `graphql:"__typename"` + IssueFieldText struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + DataType githubv4.String + } `graphql:"... on IssueFieldText"` + IssueFieldNumber struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + DataType githubv4.String + } `graphql:"... on IssueFieldNumber"` + IssueFieldDate struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + DataType githubv4.String + } `graphql:"... on IssueFieldDate"` + IssueFieldSingleSelect struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + DataType githubv4.String + Options []struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + } + } `graphql:"... on IssueFieldSingleSelect"` +} + +type issueFieldWriteMetadataQuery struct { + Repository struct { + IssueFields struct { + Nodes []issueFieldWriteMetadataNode + } `graphql:"issueFields(first: 100)"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +// IssueFieldRef resolves the name of an issue field across its concrete types. +// IssueFields is a union of IssueFieldDate, IssueFieldNumber, IssueFieldSingleSelect, IssueFieldText, +// so we have to ask for `name` on each member. +type IssueFieldRef struct { + Date struct { + Name githubv4.String + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + } `graphql:"... on IssueFieldDate"` + Number struct { + Name githubv4.String + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + } `graphql:"... on IssueFieldNumber"` + SingleSelect struct { + Name githubv4.String + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + } `graphql:"... on IssueFieldSingleSelect"` + Text struct { + Name githubv4.String + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + } `graphql:"... on IssueFieldText"` +} + +// Name returns the populated name from whichever IssueFields union variant the field resolved to. +func (r IssueFieldRef) Name() string { + switch { + case r.Date.Name != "": + return string(r.Date.Name) + case r.Number.Name != "": + return string(r.Number.Name) + case r.SingleSelect.Name != "": + return string(r.SingleSelect.Name) + case r.Text.Name != "": + return string(r.Text.Name) + } + return "" +} + +// FullDatabaseIDStr returns the fullDatabaseId string from whichever IssueFields union variant +// the field resolved to. +func (r IssueFieldRef) FullDatabaseIDStr() string { + switch { + case r.Date.FullDatabaseID != "": + return string(r.Date.FullDatabaseID) + case r.Number.FullDatabaseID != "": + return string(r.Number.FullDatabaseID) + case r.SingleSelect.FullDatabaseID != "": + return string(r.SingleSelect.FullDatabaseID) + case r.Text.FullDatabaseID != "": + return string(r.Text.FullDatabaseID) + } + return "" +} + +// IssueFieldValueFragment captures the value of a custom issue field. IssueFieldValue is a union +// of 4 concrete value types; each carries its own value scalar and a reference to its parent field. +// The Number variant's `value` is aliased to `valueNumber` to avoid a Float vs String type clash on decode. +type IssueFieldValueFragment struct { + TypeName string `graphql:"__typename"` + DateValue struct { + Field IssueFieldRef + Value githubv4.String + } `graphql:"... on IssueFieldDateValue"` + NumberValue struct { + Field IssueFieldRef + Value githubv4.Float `graphql:"valueNumber: value"` + } `graphql:"... on IssueFieldNumberValue"` + SingleSelectValue struct { + Field IssueFieldRef + Value githubv4.String + } `graphql:"... on IssueFieldSingleSelectValue"` + TextValue struct { + Field IssueFieldRef + Value githubv4.String + } `graphql:"... on IssueFieldTextValue"` +} + +func optionalIssueWriteFields(args map[string]any) ([]issueWriteFieldInput, error) { + issueFieldsRaw, exists := args["issue_fields"] + if !exists { + return nil, nil + } + + var inputMaps []map[string]any + switch v := issueFieldsRaw.(type) { + case []any: + for _, item := range v { + itemMap, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("each issue_fields item must be an object") + } + inputMaps = append(inputMaps, itemMap) + } + case []map[string]any: + inputMaps = v + default: + return nil, fmt.Errorf("issue_fields must be an array") + } + + issueFields := make([]issueWriteFieldInput, 0, len(inputMaps)) + for _, itemMap := range inputMaps { + fieldName, err := RequiredParam[string](itemMap, "field_name") + if err != nil || strings.TrimSpace(fieldName) == "" { + return nil, fmt.Errorf("field_name is required for each issue_fields item") + } + + fieldOptionName, err := OptionalParam[string](itemMap, "field_option_name") + if err != nil { + return nil, err + } + + deleteField, _ := OptionalParam[bool](itemMap, "delete") + value, hasValue := itemMap["value"] + if hasValue && value == nil { + return nil, fmt.Errorf("value cannot be null for field %q", fieldName) + } + + if deleteField { + if hasValue || fieldOptionName != "" { + return nil, fmt.Errorf("issue field %q cannot specify 'delete' together with 'value' or 'field_option_name'", fieldName) + } + issueFields = append(issueFields, issueWriteFieldInput{ + FieldName: fieldName, + Delete: true, + }) + continue + } + + if hasValue && fieldOptionName != "" { + return nil, fmt.Errorf("issue field %q cannot specify both value and field_option_name", fieldName) + } + + if !hasValue && fieldOptionName == "" { + return nil, fmt.Errorf("issue field %q must specify either value or field_option_name", fieldName) + } + + issueFields = append(issueFields, issueWriteFieldInput{ + FieldName: fieldName, + Value: value, + FieldOptionName: fieldOptionName, + }) + } + + return issueFields, nil +} + +func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueFields []issueWriteFieldInput) ([]*github.IssueRequestFieldValue, []int64, error) { + if len(issueFields) == 0 { + return nil, nil, nil + } + + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") + var query issueFieldWriteMetadataQuery + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + } + if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { + return nil, nil, fmt.Errorf("failed to query issue fields metadata: %w", err) + } + + // Build name → node map, dispatching on concrete type to extract name. + fieldByName := make(map[string]issueFieldWriteMetadataNode, len(query.Repository.IssueFields.Nodes)) + for _, node := range query.Repository.IssueFields.Nodes { + var name string + switch string(node.TypeName) { + case "IssueFieldText": + name = string(node.IssueFieldText.Name) + case "IssueFieldNumber": + name = string(node.IssueFieldNumber.Name) + case "IssueFieldDate": + name = string(node.IssueFieldDate.Name) + case "IssueFieldSingleSelect": + name = string(node.IssueFieldSingleSelect.Name) + default: + continue + } + fieldByName[strings.ToLower(strings.TrimSpace(name))] = node + } + + resolved := make([]*github.IssueRequestFieldValue, 0, len(issueFields)) + var fieldIDsToDelete []int64 + for _, fieldInput := range issueFields { + node, ok := fieldByName[strings.ToLower(strings.TrimSpace(fieldInput.FieldName))] + if !ok { + return nil, nil, fmt.Errorf("issue field %q was not found in %s/%s", fieldInput.FieldName, owner, repo) + } + + var fullDatabaseIDStr, dataType string + switch string(node.TypeName) { + case "IssueFieldText": + fullDatabaseIDStr = string(node.IssueFieldText.FullDatabaseID) + dataType = string(node.IssueFieldText.DataType) + case "IssueFieldNumber": + fullDatabaseIDStr = string(node.IssueFieldNumber.FullDatabaseID) + dataType = string(node.IssueFieldNumber.DataType) + case "IssueFieldDate": + fullDatabaseIDStr = string(node.IssueFieldDate.FullDatabaseID) + dataType = string(node.IssueFieldDate.DataType) + case "IssueFieldSingleSelect": + fullDatabaseIDStr = string(node.IssueFieldSingleSelect.FullDatabaseID) + dataType = string(node.IssueFieldSingleSelect.DataType) + } + + fieldID := parseFullDatabaseID(fullDatabaseIDStr) + if fieldID == 0 { + return nil, nil, fmt.Errorf("issue field %q is missing fullDatabaseId", fieldInput.FieldName) + } + + if fieldInput.Delete { + fieldIDsToDelete = append(fieldIDsToDelete, fieldID) + continue + } + + resolvedValue := fieldInput.Value + if fieldInput.FieldOptionName != "" { + if !strings.EqualFold(dataType, "single_select") { + return nil, nil, fmt.Errorf("issue field %q is %q, so field_option_name cannot be used", fieldInput.FieldName, dataType) + } + + optionFound := false + for _, option := range node.IssueFieldSingleSelect.Options { + if strings.EqualFold(strings.TrimSpace(string(option.Name)), strings.TrimSpace(fieldInput.FieldOptionName)) { + // REST API expects the option name, not the ID + resolvedValue = string(option.Name) + optionFound = true + break + } + } + + if !optionFound { + return nil, nil, fmt.Errorf("issue field option %q was not found for field %q", fieldInput.FieldOptionName, fieldInput.FieldName) + } + } + + resolved = append(resolved, &github.IssueRequestFieldValue{ + FieldID: fieldID, + Value: resolvedValue, + }) + } + + return resolved, fieldIDsToDelete, nil +} + +// fetchExistingIssueFieldValues retrieves the current field values for an issue +// as IssueRequestFieldValue entries, ready to be merged before an update. +func fetchExistingIssueFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int) ([]*github.IssueRequestFieldValue, error) { + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") + + var query struct { + Repository struct { + Issue struct { + IssueFieldValues struct { + Nodes []IssueFieldValueFragment + } `graphql:"issueFieldValues(first: 25)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "number": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers + } + + if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { + return nil, fmt.Errorf("failed to fetch existing issue field values: %w", err) + } + + var result []*github.IssueRequestFieldValue + for _, node := range query.Repository.Issue.IssueFieldValues.Nodes { + var fieldIDStr string + var value any + + switch node.TypeName { + case "IssueFieldDateValue": + fieldIDStr = node.DateValue.Field.FullDatabaseIDStr() + value = string(node.DateValue.Value) + case "IssueFieldNumberValue": + fieldIDStr = node.NumberValue.Field.FullDatabaseIDStr() + value = float64(node.NumberValue.Value) + case "IssueFieldSingleSelectValue": + fieldIDStr = node.SingleSelectValue.Field.FullDatabaseIDStr() + value = string(node.SingleSelectValue.Value) + case "IssueFieldTextValue": + fieldIDStr = node.TextValue.Field.FullDatabaseIDStr() + value = string(node.TextValue.Value) + default: + continue + } + + fieldID := parseFullDatabaseID(fieldIDStr) + if fieldID == 0 { + continue + } + + result = append(result, &github.IssueRequestFieldValue{ + FieldID: fieldID, + Value: value, + }) + } + + return result, nil +} + +// mergeIssueFieldValues returns a merged slice where incoming values override existing ones +// for the same field ID, and existing fields not present in incoming are preserved. +// Ordering is deterministic: incoming entries first in their original order, followed by any +// existing entries (in their original order) whose field IDs weren't seen in incoming. +func mergeIssueFieldValues(existing, incoming []*github.IssueRequestFieldValue) []*github.IssueRequestFieldValue { + seen := make(map[int64]struct{}, len(incoming)) + result := make([]*github.IssueRequestFieldValue, 0, len(existing)+len(incoming)) + for _, v := range incoming { + seen[v.FieldID] = struct{}{} + result = append(result, v) + } + for _, v := range existing { + if _, ok := seen[v.FieldID]; ok { + continue + } + result = append(result, v) + } + return result +} + // IssueFragment represents a fragment of an issue node in the GraphQL API. type IssueFragment struct { - Number githubv4.Int - Title githubv4.String - Body githubv4.String - State githubv4.String - DatabaseID int64 + Number githubv4.Int + Title githubv4.String + Body githubv4.String + State githubv4.String + DatabaseID int64 + AuthorAssociation githubv4.String Author struct { Login githubv4.String @@ -125,11 +502,15 @@ type IssueFragment struct { Comments struct { TotalCount githubv4.Int } `graphql:"comments"` + IssueFieldValues struct { + Nodes []IssueFieldValueFragment + } `graphql:"issueFieldValues(first: 25)"` } // Common interface for all issue query types type IssueQueryResult interface { GetIssueFragment() IssueQueryFragment + GetIsPrivate() bool } type IssueQueryFragment struct { @@ -146,48 +527,72 @@ type IssueQueryFragment struct { // ListIssuesQuery is the root query structure for fetching issues with optional label filtering. type ListIssuesQuery struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues})"` + IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } // ListIssuesQueryTypeWithLabels is the query structure for fetching issues with optional label filtering. type ListIssuesQueryTypeWithLabels struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues})"` + IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } // ListIssuesQueryWithSince is the query structure for fetching issues without label filtering but with since filtering. type ListIssuesQueryWithSince struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since, issueFieldValues: $issueFieldValues})"` + IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } // ListIssuesQueryTypeWithLabelsWithSince is the query structure for fetching issues with both label and since filtering. type ListIssuesQueryTypeWithLabelsWithSince struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since, issueFieldValues: $issueFieldValues})"` + IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } +// IssueFieldValueFilter mirrors the GraphQL IssueFieldValueFilter input. Exactly one typed value +// field should be set per filter (the monolith resolver rejects multiple). +type IssueFieldValueFilter struct { + FieldName githubv4.String `json:"fieldName"` + TextValue *githubv4.String `json:"textValue,omitempty"` + DateValue *githubv4.String `json:"dateValue,omitempty"` + NumberValue *githubv4.Float `json:"numberValue,omitempty"` + SingleSelectOptionValue *githubv4.String `json:"singleSelectOptionValue,omitempty"` +} + // Implement the interface for all query types func (q *ListIssuesQueryTypeWithLabels) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } +func (q *ListIssuesQueryTypeWithLabels) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } + func (q *ListIssuesQuery) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } +func (q *ListIssuesQuery) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } + func (q *ListIssuesQueryWithSince) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } +func (q *ListIssuesQueryWithSince) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } + func (q *ListIssuesQueryTypeWithLabelsWithSince) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } +func (q *ListIssuesQueryTypeWithLabelsWithSince) GetIsPrivate() bool { + return bool(q.Repository.IsPrivate) +} + func getIssueQueryType(hasLabels bool, hasSince bool) any { switch { case hasLabels && hasSince: @@ -201,6 +606,124 @@ func getIssueQueryType(hasLabels bool, hasSince bool) any { } } +// --- Legacy list_issues GraphQL types --- +// +// These mirror the pre-Issues-2.0 shape of the list_issues query and exist solely +// to back the FeatureFlagIssueFields-disabled variant of the tool. They omit the +// IssueFieldValues selection and the filterBy: {issueFieldValues: ...} clause so +// the request does not depend on server-side issue_fields GraphQL features and +// does not pay the wire/server cost of fetching custom field values when the flag +// is off. Delete this whole block (and its callers) when FeatureFlagIssueFields +// is removed. + +type LegacyIssueFragment struct { + Number githubv4.Int + Title githubv4.String + Body githubv4.String + State githubv4.String + DatabaseID int64 + AuthorAssociation githubv4.String + + Author struct { + Login githubv4.String + } + CreatedAt githubv4.DateTime + UpdatedAt githubv4.DateTime + Labels struct { + Nodes []struct { + Name githubv4.String + ID githubv4.String + Description githubv4.String + } + } `graphql:"labels(first: 100)"` + Comments struct { + TotalCount githubv4.Int + } `graphql:"comments"` +} + +type LegacyIssueQueryFragment struct { + Nodes []LegacyIssueFragment `graphql:"nodes"` + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int +} + +type LegacyIssueQueryResult interface { + GetLegacyIssueFragment() LegacyIssueQueryFragment + GetIsPrivate() bool +} + +type LegacyListIssuesQuery struct { + Repository struct { + Issues LegacyIssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + IsPrivate githubv4.Boolean + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +type LegacyListIssuesQueryTypeWithLabels struct { + Repository struct { + Issues LegacyIssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + IsPrivate githubv4.Boolean + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +type LegacyListIssuesQueryWithSince struct { + Repository struct { + Issues LegacyIssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + IsPrivate githubv4.Boolean + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +type LegacyListIssuesQueryTypeWithLabelsWithSince struct { + Repository struct { + Issues LegacyIssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + IsPrivate githubv4.Boolean + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +func (q *LegacyListIssuesQuery) GetLegacyIssueFragment() LegacyIssueQueryFragment { + return q.Repository.Issues +} +func (q *LegacyListIssuesQuery) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } + +func (q *LegacyListIssuesQueryTypeWithLabels) GetLegacyIssueFragment() LegacyIssueQueryFragment { + return q.Repository.Issues +} +func (q *LegacyListIssuesQueryTypeWithLabels) GetIsPrivate() bool { + return bool(q.Repository.IsPrivate) +} + +func (q *LegacyListIssuesQueryWithSince) GetLegacyIssueFragment() LegacyIssueQueryFragment { + return q.Repository.Issues +} +func (q *LegacyListIssuesQueryWithSince) GetIsPrivate() bool { + return bool(q.Repository.IsPrivate) +} + +func (q *LegacyListIssuesQueryTypeWithLabelsWithSince) GetLegacyIssueFragment() LegacyIssueQueryFragment { + return q.Repository.Issues +} +func (q *LegacyListIssuesQueryTypeWithLabelsWithSince) GetIsPrivate() bool { + return bool(q.Repository.IsPrivate) +} + +func getLegacyIssueQueryType(hasLabels bool, hasSince bool) any { + switch { + case hasLabels && hasSince: + return &LegacyListIssuesQueryTypeWithLabelsWithSince{} + case hasLabels: + return &LegacyListIssuesQueryTypeWithLabels{} + case hasSince: + return &LegacyListIssuesQueryWithSince{} + default: + return &LegacyListIssuesQuery{} + } +} + // IssueRead creates a tool to get details of a specific issue in a GitHub repository. func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ @@ -280,19 +803,37 @@ Options are: return utils.NewToolResultErrorFromErr("failed to get GitHub graphql client", err), nil, nil } + // attachIFC adds the IFC label to a successful tool result when + // IFC labels are enabled. If the visibility lookup fails the + // label is omitted rather than misclassifying the result. + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + return r + } + isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo) + if err != nil { + return r + } + if r.Meta == nil { + r.Meta = mcp.Meta{} + } + r.Meta["ifc"] = ifc.LabelListIssues(isPrivate) + return r + } + switch method { case "get": result, err := GetIssue(ctx, client, deps, owner, repo, issueNumber) - return result, nil, err + return attachIFC(result), nil, err case "get_comments": result, err := GetIssueComments(ctx, client, deps, owner, repo, issueNumber, pagination) - return result, nil, err + return attachIFC(result), nil, err case "get_sub_issues": result, err := GetSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination) - return result, nil, err + return attachIFC(result), nil, err case "get_labels": result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber) - return result, nil, err + return attachIFC(result), nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } @@ -348,6 +889,20 @@ func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, minimalIssue := convertToMinimalIssue(issue) + // Always drop the verbose REST IssueFieldValues; only enrich with the GraphQL + // field_values view when the issue-fields feature flag is on. + minimalIssue.IssueFieldValues = nil + if deps.IsFeatureEnabled(ctx, FeatureFlagIssueFields) { + if issue != nil && issue.NodeID != nil && *issue.NodeID != "" { + gqlClient, err := deps.GetGQLClient(ctx) + if err == nil { + if fieldValuesByID, err := fetchIssueFieldValuesByNodeID(ctx, gqlClient, []*github.Issue{issue}); err == nil { + minimalIssue.FieldValues = fieldValuesByID[*issue.NodeID] + } + } + } + } + return MarshalledTextResult(minimalIssue), nil } @@ -418,11 +973,9 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc } featureFlags := deps.GetFlags(ctx) - opts := &github.IssueListOptions{ - ListOptions: github.ListOptions{ - Page: pagination.Page, - PerPage: pagination.PerPage, - }, + opts := &github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, } subIssues, resp, err := client.SubIssue.ListByIssue(ctx, owner, repo, int64(issueNumber), opts) @@ -527,7 +1080,6 @@ func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, } return utils.NewToolResultText(string(out)), nil - } // ListIssueTypes creates a tool to list defined issue types for an organization. This can be used to understand supported issue type values for creating or updating issues. @@ -677,7 +1229,7 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool // SubIssueWrite creates a tool to add a sub-issue to a parent issue. func SubIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + st := NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "sub_issue_write", @@ -787,6 +1339,8 @@ Options are: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }) + st.FeatureFlagDisable = []string{FeatureFlagIssuesGranular} + return st } func AddSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int, replaceParent bool) (*mcp.CallToolResult, error) { @@ -820,7 +1374,6 @@ func AddSubIssue(ctx context.Context, client *github.Client, owner string, repo } return utils.NewToolResultText(string(r)), nil - } func RemoveSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int) (*mcp.CallToolResult, error) { @@ -960,77 +1513,630 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, []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") + var options []searchOption + if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + options = append(options, withSearchPostProcess(searchIssuesIFCPostProcess(deps))) + } + result, err := searchIssuesHandler(ctx, deps, args, options...) return result, nil, err }) } -// IssueWrite creates a tool to create a new or update an existing issue in a GitHub repository. -// IssueWriteUIResourceURI is the URI for the issue_write tool's MCP App UI resource. -const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write" +// searchIssuesIFCPostProcess returns a searchPostProcessFn that attaches the +// IFC label for a search_issues result. It looks up the visibility (and, for +// private repos, collaborators) of every repository represented in the search +// payload and joins the labels via ifc.LabelSearchIssues. If any per-repo +// lookup fails the label is omitted to avoid misclassifying the result. +func searchIssuesIFCPostProcess(deps ToolDependencies) searchPostProcessFn { + return func(ctx context.Context, result *github.IssuesSearchResult, callResult *mcp.CallToolResult) { + if callResult == nil || callResult.IsError || result == nil { + return + } -func IssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( - ToolsetMetadataIssues, - mcp.Tool{ - Name: "issue_write", - Description: t("TOOL_ISSUE_WRITE_DESCRIPTION", "Create a new or update an existing issue in a GitHub repository."), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue."), - ReadOnlyHint: false, - }, - Meta: mcp.Meta{ - "ui": map[string]any{ - "resourceUri": IssueWriteUIResourceURI, - "visibility": []string{"model", "app"}, - }, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "method": { - Type: "string", - Description: `Write operation to perform on a single issue. -Options are: -- 'create' - creates a new issue. -- 'update' - updates an existing issue. -`, - Enum: []any{"create", "update"}, - }, - "owner": { - Type: "string", - Description: "Repository owner", - }, - "repo": { - Type: "string", - Description: "Repository name", - }, - "issue_number": { - Type: "number", - Description: "Issue number to update", - }, - "title": { - Type: "string", - Description: "Issue title", - }, - "body": { - Type: "string", - Description: "Issue body content", - }, - "assignees": { - Type: "array", - Description: "Usernames to assign to this issue", - Items: &jsonschema.Schema{ - Type: "string", - }, - }, - "labels": { - Type: "array", - Description: "Labels to apply to this issue", - Items: &jsonschema.Schema{ - Type: "string", - }, - }, + client, err := deps.GetClient(ctx) + if err != nil { + return + } + + uniqueRepos := uniqueSearchIssuesRepos(result) + visibilities := make([]bool, 0, len(uniqueRepos)) + for _, r := range uniqueRepos { + isPrivate, err := FetchRepoIsPrivate(ctx, client, r.owner, r.repo) + if err != nil { + return + } + visibilities = append(visibilities, isPrivate) + } + + if callResult.Meta == nil { + callResult.Meta = mcp.Meta{} + } + callResult.Meta["ifc"] = ifc.LabelSearchIssues(visibilities) + } +} + +type searchIssuesRepoRef struct { + owner string + repo string +} + +// uniqueSearchIssuesRepos extracts the owner/repo pairs of every issue in the +// search result, preserving order of first appearance and deduplicating. +func uniqueSearchIssuesRepos(result *github.IssuesSearchResult) []searchIssuesRepoRef { + if result == nil { + return nil + } + seen := make(map[string]struct{}) + var out []searchIssuesRepoRef + for _, issue := range result.Issues { + if issue == nil { + continue + } + owner, repo, ok := parseRepositoryURL(issue.GetRepositoryURL()) + if !ok { + continue + } + key := owner + "/" + repo + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + out = append(out, searchIssuesRepoRef{owner: owner, repo: repo}) + } + return out +} + +// parseRepositoryURL extracts the owner and repo from a GitHub API repository +// URL of the form https://api.github.com/repos/{owner}/{repo}. +func parseRepositoryURL(repoURL string) (string, string, bool) { + if repoURL == "" { + return "", "", false + } + const marker = "/repos/" + idx := strings.LastIndex(repoURL, marker) + if idx < 0 { + return "", "", false + } + parts := strings.Split(strings.Trim(repoURL[idx+len(marker):], "/"), "/") + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} + +// SearchIssueResult wraps a REST search hit with its custom issue field values, fetched in a follow-up GraphQL nodes() query. +type SearchIssueResult struct { + *github.Issue + FieldValues []MinimalFieldValue `json:"field_values,omitempty"` +} + +// MarshalJSON serializes SearchIssueResult, suppressing the raw issue_field_values from the +// embedded REST response in favour of the normalized field_values populated via GraphQL enrichment. +func (r SearchIssueResult) MarshalJSON() ([]byte, error) { + issueBytes, err := json.Marshal(r.Issue) + if err != nil { + return nil, err + } + var m map[string]json.RawMessage + if err := json.Unmarshal(issueBytes, &m); err != nil { + return nil, err + } + delete(m, "issue_field_values") + if r.FieldValues != nil { + fv, err := json.Marshal(r.FieldValues) + if err != nil { + return nil, err + } + m["field_values"] = fv + } + return json.Marshal(m) +} + +// SearchIssuesResponse mirrors the REST IssuesSearchResult JSON shape and adds field_values +// per item, sourced from a single GraphQL nodes() round-trip. +type SearchIssuesResponse struct { + Total *int `json:"total_count,omitempty"` + IncompleteResults *bool `json:"incomplete_results,omitempty"` + Items []SearchIssueResult `json:"items"` +} + +// searchIssuesNodesQuery batches a nodes(ids:) lookup over the REST search results to retrieve +// each issue's custom field values in a single GraphQL request. +type searchIssuesNodesQuery struct { + Nodes []struct { + Issue struct { + ID githubv4.ID + IssueFieldValues struct { + Nodes []IssueFieldValueFragment + } `graphql:"issueFieldValues(first: 25)"` + } `graphql:"... on Issue"` + } `graphql:"nodes(ids: $ids)"` +} + +// fetchIssueFieldValuesByNodeID runs one GraphQL nodes() query for the given REST issues and +// returns a map of node_id -> flattened field values. Issues without a node_id are skipped, and +// an empty result set short-circuits the round-trip. +func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, issues []*github.Issue) (map[string][]MinimalFieldValue, error) { + ids := make([]githubv4.ID, 0, len(issues)) + for _, iss := range issues { + if iss == nil || iss.NodeID == nil || *iss.NodeID == "" { + continue + } + ids = append(ids, githubv4.ID(*iss.NodeID)) + } + if len(ids) == 0 { + return nil, nil + } + + var q searchIssuesNodesQuery + if err := gqlClient.Query(ctx, &q, map[string]any{"ids": ids}); err != nil { + return nil, err + } + + result := make(map[string][]MinimalFieldValue, len(q.Nodes)) + for _, n := range q.Nodes { + idStr, ok := n.Issue.ID.(string) + if !ok || idStr == "" { + continue + } + vals := make([]MinimalFieldValue, 0, len(n.Issue.IssueFieldValues.Nodes)) + for _, fv := range n.Issue.IssueFieldValues.Nodes { + if m, ok := fragmentToMinimalFieldValue(fv); ok { + vals = append(vals, m) + } + } + result[idStr] = vals + } + return result, nil +} + +// searchIssuesHandler runs the REST issues search, enriches each hit with custom field values +// fetched via a single follow-up GraphQL nodes() query, and applies any post-process options +// (e.g. IFC labelling). +func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any, options ...searchOption) (*mcp.CallToolResult, error) { + const errorPrefix = "failed to search issues" + + query, opts, err := prepareSearchArgs(args, "issue") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), nil + } + result, resp, err := client.Search.Issues(ctx, query, opts) + if err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix, err), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to read response body", err), nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil + } + + var fieldValuesByID map[string][]MinimalFieldValue + if len(result.Issues) > 0 { + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub GraphQL client", err), nil + } + fieldValuesByID, err = fetchIssueFieldValuesByNodeID(ctx, gqlClient, result.Issues) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, errorPrefix+": failed to fetch issue field values", err), nil + } + } + + items := make([]SearchIssueResult, 0, len(result.Issues)) + for _, iss := range result.Issues { + hit := SearchIssueResult{Issue: iss} + if iss != nil && iss.NodeID != nil { + hit.FieldValues = fieldValuesByID[*iss.NodeID] + } + items = append(items, hit) + } + + response := SearchIssuesResponse{ + Total: result.Total, + IncompleteResults: result.IncompleteResults, + Items: items, + } + + r, err := json.Marshal(response) + if err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil + } + + callResult := utils.NewToolResultText(string(r)) + cfg := searchConfig{} + for _, opt := range options { + opt(&cfg) + } + if cfg.postProcess != nil { + cfg.postProcess(ctx, result, callResult) + } + return callResult, nil +} + +// IssueWriteUIResourceURI is the URI for the issue_write tool's MCP App UI resource. +const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write" + +// issueWriteFormParams are the parameters the issue_write MCP App form collects +// and re-sends on submit. The form only supports title/body editing (plus the +// routing/identity fields), so any other parameter present on a call cannot be +// represented by the form. +var issueWriteFormParams = map[string]struct{}{ + "method": {}, + "owner": {}, + "repo": {}, + "title": {}, + "body": {}, + "issue_number": {}, + "_ui_submitted": {}, +} + +// issueWriteHasNonFormParams reports whether the call carries any parameter the +// issue_write MCP App form cannot represent (anything outside issueWriteFormParams, +// e.g. labels, assignees, issue_fields or a state change). Such calls must bypass +// the UI form and execute directly so the supplied values aren't silently dropped. +func issueWriteHasNonFormParams(args map[string]any) bool { + for key, value := range args { + if value == nil { + continue + } + if _, ok := issueWriteFormParams[key]; !ok { + return true + } + } + return false +} + +// IssueWrite is the FeatureFlagIssueFields-enabled variant of issue_write +// (with the issue_fields parameter). LegacyIssueWrite is served when the flag +// is off. Both register under the tool name "issue_write"; exactly one is +// active at a time via mutually exclusive feature-flag annotations. When the +// flag is removed, delete LegacyIssueWrite outright and drop the feature-flag +// fields on IssueWrite. +func IssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "issue_write", + Description: t("TOOL_ISSUE_WRITE_DESCRIPTION", "Create a new or update an existing issue in a GitHub repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue"), + ReadOnlyHint: false, + }, + Meta: mcp.Meta{ + "ui": map[string]any{ + "resourceUri": IssueWriteUIResourceURI, + "visibility": []string{"model", "app"}, + }, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: `Write operation to perform on a single issue. +Options are: +- 'create' - creates a new issue. +- 'update' - updates an existing issue. +`, + Enum: []any{"create", "update"}, + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "Issue number to update", + }, + "title": { + Type: "string", + Description: "Issue title", + }, + "body": { + Type: "string", + Description: "Issue body content", + }, + "assignees": { + Type: "array", + Description: "Usernames to assign to this issue", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "labels": { + Type: "array", + Description: "Labels to apply to this issue", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "milestone": { + Type: "number", + Description: "Milestone number", + }, + "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.", + }, + "state": { + Type: "string", + Description: "New state", + Enum: []any{"open", "closed"}, + }, + "state_reason": { + Type: "string", + Description: "Reason for the state change. Ignored unless state is changed.", + Enum: []any{"completed", "not_planned", "duplicate"}, + }, + "duplicate_of": { + Type: "number", + Description: "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", + }, + "issue_fields": { + Type: "array", + Description: "Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'.", + Items: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "field_name": { + Type: "string", + Description: "Issue field name (case-insensitive). Must match a field " + + "returned by list_issue_fields for this repository or its organization.", + }, + "value": { + Types: []string{"string", "number", "boolean"}, + Description: "Value to set. Use for text, number, and date fields " + + "(date as YYYY-MM-DD). For single-select fields, prefer " + + "'field_option_name' so the option is validated before the API " + + "call. Cannot be combined with 'field_option_name' or 'delete'.", + }, + "field_option_name": { + Type: "string", + Description: "Option name for single-select fields. Validated against " + + "the field's options before the API call. Cannot be combined with " + + "'value' or 'delete'.", + }, + "delete": { + Type: "boolean", + Enum: []any{true}, + Description: "Set to true to clear this field's current value on the " + + "issue. Cannot be combined with 'value' or 'field_option_name'.", + }, + }, + Required: []string{"field_name"}, + }, + }, + }, + Required: []string{"method", "owner", "repo"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, req *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 + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // When MCP Apps are enabled and the client supports UI, route the + // call to the interactive form unless it is itself a form submission + // (the UI sends _ui_submitted=true) or it carries parameters the form + // cannot represent (e.g. labels, assignees or issue_fields). Those + // must be applied directly so their values aren't silently dropped. + uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") + + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !issueWriteHasNonFormParams(args) { + if method == "update" { + issueNumber, numErr := RequiredInt(args, "issue_number") + if numErr != nil { + return utils.NewToolResultError("issue_number is required for update method"), nil, nil + } + return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil + } + return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil + } + + title, err := OptionalParam[string](args, "title") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Optional parameters + body, err := OptionalParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Get assignees + assignees, err := OptionalStringArrayParam(args, "assignees") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + assigneesValue, assigneesProvided := args["assignees"] + assigneesProvided = assigneesProvided && assigneesValue != nil + + // Get labels + labels, err := OptionalStringArrayParam(args, "labels") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + labelsValue, labelsProvided := args["labels"] + labelsProvided = labelsProvided && labelsValue != nil + + // Get optional milestone + milestone, err := OptionalIntParam(args, "milestone") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var milestoneNum int + if milestone != 0 { + milestoneNum = milestone + } + + // Get optional type + issueType, err := OptionalParam[string](args, "type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Handle state, state_reason and duplicateOf parameters + state, err := OptionalParam[string](args, "state") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + stateReason, err := OptionalParam[string](args, "state_reason") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + duplicateOf, err := OptionalIntParam(args, "duplicate_of") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if duplicateOf != 0 && stateReason != "duplicate" { + return utils.NewToolResultError("duplicate_of can only be used when state_reason is 'duplicate'"), nil, nil + } + + var issueFields []issueWriteFieldInput + issueFields, err = optionalIssueWriteFields(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GraphQL client", err), nil, nil + } + + var issueFieldValues []*github.IssueRequestFieldValue + var fieldIDsToDelete []int64 + if len(issueFields) > 0 { + issueFieldValues, fieldIDsToDelete, err = resolveIssueRequestFieldValues(ctx, gqlClient, owner, repo, issueFields) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to resolve issue_fields: %v", err)), nil, nil + } + } + + switch method { + case "create": + result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues) + return result, nil, err + case "update": + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf, UpdateIssueOptions{ + AssigneesProvided: assigneesProvided, + LabelsProvided: labelsProvided, + }) + return result, nil, err + default: + return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil + } + }) + st.FeatureFlagEnable = FeatureFlagIssueFields + st.FeatureFlagDisable = []string{FeatureFlagIssuesGranular} + return st +} + +// LegacyIssueWrite is the FeatureFlagIssueFields-disabled variant of issue_write. +// It is a near-verbatim copy of IssueWrite minus the issue_fields schema +// property, the issue_fields handler block, and the related GraphQL field +// resolution. Kept as a full duplicate so removing the FeatureFlagIssueFields +// flag is a single-function delete. Hidden whenever the granular toolset or +// the issue-fields flag is on. +func LegacyIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "issue_write", + Description: t("TOOL_ISSUE_WRITE_DESCRIPTION", "Create a new or update an existing issue in a GitHub repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue"), + ReadOnlyHint: false, + }, + Meta: mcp.Meta{ + "ui": map[string]any{ + "resourceUri": IssueWriteUIResourceURI, + "visibility": []string{"model", "app"}, + }, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: `Write operation to perform on a single issue. +Options are: +- 'create' - creates a new issue. +- 'update' - updates an existing issue. +`, + Enum: []any{"create", "update"}, + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "Issue number to update", + }, + "title": { + Type: "string", + Description: "Issue title", + }, + "body": { + Type: "string", + Description: "Issue body content", + }, + "assignees": { + Type: "array", + Description: "Usernames to assign to this issue", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "labels": { + Type: "array", + Description: "Labels to apply to this issue", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, "milestone": { Type: "number", Description: "Milestone number", @@ -1073,26 +2179,22 @@ Options are: return utils.NewToolResultError(err.Error()), nil, nil } - // When insiders mode is enabled and the client supports MCP Apps UI, - // check if this is a UI form submission. The UI sends _ui_submitted=true - // to distinguish form submissions from LLM calls. + // When MCP Apps are enabled and the client supports UI, route the + // call to the interactive form unless it is itself a form submission + // (the UI sends _ui_submitted=true) or it carries parameters the form + // cannot represent (e.g. labels, assignees or issue_fields). Those + // must be applied directly so their values aren't silently dropped. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") - if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(ctx, req) && !uiSubmitted { + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !issueWriteHasNonFormParams(args) { if method == "update" { - // Skip the UI form when a state change is requested because - // the form only handles title/body editing and would lose the - // state transition (e.g. closing or reopening the issue). - if _, hasState := args["state"]; !hasState { - issueNumber, numErr := RequiredInt(args, "issue_number") - if numErr != nil { - return utils.NewToolResultError("issue_number is required for update method"), nil, nil - } - return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil + issueNumber, numErr := RequiredInt(args, "issue_number") + if numErr != nil { + return utils.NewToolResultError("issue_number is required for update method"), nil, nil } - } else { - return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil + return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil } + return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil } title, err := OptionalParam[string](args, "title") @@ -1111,12 +2213,16 @@ Options are: if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + assigneesValue, assigneesProvided := args["assignees"] + assigneesProvided = assigneesProvided && assigneesValue != nil // Get labels labels, err := OptionalStringArrayParam(args, "labels") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + labelsValue, labelsProvided := args["labels"] + labelsProvided = labelsProvided && labelsValue != nil // Get optional milestone milestone, err := OptionalIntParam(args, "milestone") @@ -1166,32 +2272,38 @@ Options are: switch method { case "create": - result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType) + result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType, nil) return result, nil, err case "update": issueNumber, err := RequiredInt(args, "issue_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, state, stateReason, duplicateOf) + result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, nil, nil, state, stateReason, duplicateOf, UpdateIssueOptions{ + AssigneesProvided: assigneesProvided, + LabelsProvided: labelsProvided, + }) return result, nil, err default: return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil } }) + st.FeatureFlagDisable = []string{FeatureFlagIssuesGranular, FeatureFlagIssueFields} + return st } -func CreateIssue(ctx context.Context, client *github.Client, owner string, repo string, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string) (*mcp.CallToolResult, error) { +func CreateIssue(ctx context.Context, client *github.Client, owner string, repo string, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue) (*mcp.CallToolResult, error) { if title == "" { return utils.NewToolResultError("missing required parameter: title"), nil } // Create the issue request issueRequest := &github.IssueRequest{ - Title: github.Ptr(title), - Body: github.Ptr(body), - Assignees: &assignees, - Labels: &labels, + Title: github.Ptr(title), + Body: github.Ptr(body), + Assignees: &assignees, + Labels: &labels, + IssueFieldValues: issueFieldValues, } if milestoneNum != 0 { @@ -1234,7 +2346,24 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo return utils.NewToolResultText(string(r)), nil } -func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, state string, stateReason string, duplicateOf int) (*mcp.CallToolResult, error) { +// UpdateIssueOptions controls which optional fields are included in an issue update request. +type UpdateIssueOptions struct { + // AssigneesProvided sends the assignees field even when the slice is empty. + AssigneesProvided bool + // LabelsProvided sends the labels field even when the slice is empty. + LabelsProvided bool +} + +func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue, fieldIDsToDelete []int64, state string, stateReason string, duplicateOf int, opts ...UpdateIssueOptions) (*mcp.CallToolResult, error) { + updateOptions := UpdateIssueOptions{ + AssigneesProvided: len(assignees) > 0, + LabelsProvided: len(labels) > 0, + } + for _, opt := range opts { + updateOptions.AssigneesProvided = updateOptions.AssigneesProvided || opt.AssigneesProvided + updateOptions.LabelsProvided = updateOptions.LabelsProvided || opt.LabelsProvided + } + // Create the issue request with only provided fields issueRequest := &github.IssueRequest{} @@ -1247,11 +2376,11 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 issueRequest.Body = github.Ptr(body) } - if len(labels) > 0 { + if updateOptions.LabelsProvided { issueRequest.Labels = &labels } - if len(assignees) > 0 { + if updateOptions.AssigneesProvided { issueRequest.Assignees = &assignees } @@ -1263,6 +2392,31 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 issueRequest.Type = github.Ptr(issueType) } + if len(issueFieldValues) > 0 || len(fieldIDsToDelete) > 0 { + // The REST update endpoint uses "set" semantics — it overwrites all existing + // field values with whatever is sent. Fetch the current values first, merge in + // the new values, then remove any explicitly deleted fields. + existing, err := fetchExistingIssueFieldValues(ctx, gqlClient, owner, repo, issueNumber) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to fetch existing issue field values", err), nil + } + merged := mergeIssueFieldValues(existing, issueFieldValues) + if len(fieldIDsToDelete) > 0 { + deleteSet := make(map[int64]bool, len(fieldIDsToDelete)) + for _, id := range fieldIDsToDelete { + deleteSet[id] = true + } + kept := make([]*github.IssueRequestFieldValue, 0, len(merged)) + for _, v := range merged { + if !deleteSet[v.FieldID] { + kept = append(kept, v) + } + } + merged = kept + } + issueRequest.IssueFieldValues = merged + } + updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, @@ -1359,7 +2513,11 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 return utils.NewToolResultText(string(r)), nil } -// ListIssues creates a tool to list and filter repository issues +// ListIssues creates a tool to list and filter repository issues. This variant is +// gated by FeatureFlagIssueFields and exposes the Issues 2.0 field_filters input +// plus field_values output enrichment. When the flag is off, LegacyListIssues is +// served instead. Both registrations share the tool name "list_issues" and rely on +// the inventory's feature-flag filter to make exactly one active at a time. func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", @@ -1398,12 +2556,30 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { Type: "string", Description: "Filter by date (ISO 8601 timestamp)", }, + "field_filters": { + Type: "array", + Description: "Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date).", + Items: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "field_name": { + Type: "string", + Description: "Name of the custom field (e.g. \"Priority\"). Case-insensitive.", + }, + "value": { + Type: "string", + Description: "Value to filter on. For single-select fields, the option name (e.g. \"P1\"). For dates, YYYY-MM-DD. For numbers, the numeric value as a string. For text, the text value.", + }, + }, + Required: []string{"field_name", "value"}, + }, + }, }, Required: []string{"owner", "repo"}, } WithCursorPagination(schema) - return NewTool( + st := NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issues", @@ -1493,6 +2669,11 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } hasLabels := len(labels) > 0 + rawFilters, err := parseRawFieldFilters(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + // Get pagination parameters and convert to GraphQL format pagination, err := OptionalCursorPaginationParams(args) if err != nil { @@ -1524,13 +2705,28 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } + // Resolve field filters by looking up the repo's issue fields so we can + // coerce each value into the right typed slot on IssueFieldValueFilter. + fieldFilters := []IssueFieldValueFilter{} + if len(rawFilters) > 0 { + fields, err := fetchIssueFields(ctx, client, owner, repo) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to look up issue fields for field_filters", err), nil, nil + } + fieldFilters, err = resolveFieldFilters(rawFilters, fields) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } + vars := map[string]any{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "states": states, - "orderBy": githubv4.IssueOrderField(orderBy), - "direction": githubv4.OrderDirection(direction), - "first": githubv4.Int(*paginationParams.First), + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "states": states, + "orderBy": githubv4.IssueOrderField(orderBy), + "direction": githubv4.OrderDirection(direction), + "first": githubv4.Int(*paginationParams.First), + "issueFieldValues": fieldFilters, } if paginationParams.After != nil { @@ -1555,7 +2751,11 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } issueQuery := getIssueQueryType(hasLabels, hasSince) - if err := client.Query(ctx, issueQuery, vars); err != nil { + // The list_issues query references the issue_fields-gated IssueFieldValueFilter + // input type unconditionally, so we always opt into the feature via header. This + // is a no-op once the flags are globally rolled out. + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") + if err := client.Query(ctxWithFeatures, issueQuery, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse( ctx, "failed to list issues", @@ -1564,12 +2764,336 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } var resp MinimalIssuesResponse + var isPrivate bool if queryResult, ok := issueQuery.(IssueQueryResult); ok { resp = convertToMinimalIssuesResponse(queryResult.GetIssueFragment()) + isPrivate = queryResult.GetIsPrivate() + } + + result := MarshalledTextResult(resp) + if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + if result.Meta == nil { + result.Meta = mcp.Meta{} + } + result.Meta["ifc"] = ifc.LabelListIssues(isPrivate) + } + return result, nil, nil + }) + st.FeatureFlagEnable = FeatureFlagIssueFields + return st +} + +// LegacyListIssues is the FeatureFlagIssueFields-disabled variant of list_issues. +// It exposes the pre-Issues-2.0 schema (no field_filters) and uses a GraphQL query +// path that does not select issueFieldValues or pass the issue_fields filter, so +// the request does not depend on server-side issue_fields features and does not pay +// for custom field values when the flag is off. Both this and ListIssues register +// under the tool name "list_issues"; exactly one is active for any given request +// thanks to mutually exclusive FeatureFlagEnable / FeatureFlagDisable annotations. +// Delete this function (and the rest of the Legacy* block) when the flag is removed. +func LegacyListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "state": { + Type: "string", + Description: "Filter by state, by default both open and closed issues are returned when not provided", + Enum: []any{"OPEN", "CLOSED"}, + }, + "labels": { + Type: "array", + Description: "Filter by labels", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "orderBy": { + Type: "string", + Description: "Order issues by field. If provided, the 'direction' also needs to be provided.", + Enum: []any{"CREATED_AT", "UPDATED_AT", "COMMENTS"}, + }, + "direction": { + Type: "string", + Description: "Order direction. If provided, the 'orderBy' also needs to be provided.", + Enum: []any{"ASC", "DESC"}, + }, + "since": { + Type: "string", + Description: "Filter by date (ISO 8601 timestamp)", + }, + }, + Required: []string{"owner", "repo"}, + } + WithCursorPagination(schema) + + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "list_issues", + Description: t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_ISSUES_USER_TITLE", "List issues"), + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + state, err := OptionalParam[string](args, "state") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + state = strings.ToUpper(state) + var states []githubv4.IssueState + switch state { + case "OPEN", "CLOSED": + states = []githubv4.IssueState{githubv4.IssueState(state)} + default: + states = []githubv4.IssueState{githubv4.IssueStateOpen, githubv4.IssueStateClosed} } - return MarshalledTextResult(resp), nil, nil + labels, err := OptionalStringArrayParam(args, "labels") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + orderBy, err := OptionalParam[string](args, "orderBy") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + direction, err := OptionalParam[string](args, "direction") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + orderBy = strings.ToUpper(orderBy) + switch orderBy { + case "CREATED_AT", "UPDATED_AT", "COMMENTS": + default: + orderBy = "CREATED_AT" + } + direction = strings.ToUpper(direction) + switch direction { + case "ASC", "DESC": + default: + direction = "DESC" + } + + since, err := OptionalParam[string](args, "since") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + var sinceTime time.Time + var hasSince bool + if since != "" { + sinceTime, err = parseISOTimestamp(since) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil, nil + } + hasSince = true + } + hasLabels := len(labels) > 0 + + pagination, err := OptionalCursorPaginationParams(args) + if err != nil { + return nil, nil, err + } + if _, pageProvided := args["page"]; pageProvided { + return utils.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil, nil + } + _, perPageProvided := args["perPage"] + paginationExplicit := perPageProvided + paginationParams, err := pagination.ToGraphQLParams() + if err != nil { + return nil, nil, err + } + if !paginationExplicit { + defaultFirst := int32(DefaultGraphQLPageSize) + paginationParams.First = &defaultFirst + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "states": states, + "orderBy": githubv4.IssueOrderField(orderBy), + "direction": githubv4.OrderDirection(direction), + "first": githubv4.Int(*paginationParams.First), + } + if paginationParams.After != nil { + vars["after"] = githubv4.String(*paginationParams.After) + } else { + vars["after"] = (*githubv4.String)(nil) + } + if hasLabels { + labelStrings := make([]githubv4.String, len(labels)) + for i, label := range labels { + labelStrings[i] = githubv4.String(label) + } + vars["labels"] = labelStrings + } + if hasSince { + vars["since"] = githubv4.DateTime{Time: sinceTime} + } + + issueQuery := getLegacyIssueQueryType(hasLabels, hasSince) + if err := client.Query(ctx, issueQuery, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse( + ctx, + "failed to list issues", + err, + ), nil, nil + } + + var resp MinimalIssuesResponse + var isPrivate bool + if queryResult, ok := issueQuery.(LegacyIssueQueryResult); ok { + resp = convertLegacyToMinimalIssuesResponse(queryResult.GetLegacyIssueFragment()) + isPrivate = queryResult.GetIsPrivate() + } + + result := MarshalledTextResult(resp) + if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + if result.Meta == nil { + result.Meta = mcp.Meta{} + } + result.Meta["ifc"] = ifc.LabelListIssues(isPrivate) + } + return result, nil, nil }) + st.FeatureFlagDisable = []string{FeatureFlagIssueFields} + return st +} + +// rawFieldFilter is the user-supplied {field_name, value} pair before type resolution. +type rawFieldFilter struct { + Name string + Value string +} + +// parseRawFieldFilters extracts the optional field_filters parameter into a list of +// {name, value} pairs. The value is always a string here; type-aware coercion happens +// later in resolveFieldFilters once we know each field's data_type. +func parseRawFieldFilters(args map[string]any) ([]rawFieldFilter, error) { + raw, ok := args["field_filters"] + if !ok { + return nil, nil + } + + var entries []map[string]any + switch v := raw.(type) { + case []any: + for _, f := range v { + entry, ok := f.(map[string]any) + if !ok { + return nil, fmt.Errorf("each field_filters entry must be an object") + } + entries = append(entries, entry) + } + case []map[string]any: + entries = v + default: + return nil, fmt.Errorf("field_filters must be an array") + } + + filters := make([]rawFieldFilter, 0, len(entries)) + for _, entry := range entries { + fieldName, err := RequiredParam[string](entry, "field_name") + if err != nil { + return nil, fmt.Errorf("field_filters entry: %s", err.Error()) + } + value, err := RequiredParam[string](entry, "value") + if err != nil { + return nil, fmt.Errorf("field_filters entry %q: %s", fieldName, err.Error()) + } + filters = append(filters, rawFieldFilter{Name: fieldName, Value: value}) + } + return filters, nil +} + +// resolveFieldFilters matches each raw filter against a known field definition and +// coerces the value into the right typed slot on IssueFieldValueFilter. Matching is +// case-insensitive on field name; option names are also matched case-insensitively for +// single-select fields. +func resolveFieldFilters(rawFilters []rawFieldFilter, fields []IssueField) ([]IssueFieldValueFilter, error) { + byName := make(map[string]IssueField, len(fields)) + knownNames := make([]string, 0, len(fields)) + for _, f := range fields { + byName[strings.ToLower(f.Name)] = f + knownNames = append(knownNames, f.Name) + } + + out := make([]IssueFieldValueFilter, 0, len(rawFilters)) + for _, rf := range rawFilters { + field, ok := byName[strings.ToLower(rf.Name)] + if !ok { + return nil, fmt.Errorf("field_filters: unknown field %q. Known fields: %s", rf.Name, strings.Join(knownNames, ", ")) + } + + filter := IssueFieldValueFilter{FieldName: githubv4.String(field.Name)} + switch field.DataType { + case "SINGLE_SELECT": + // Validate the option name against the field's options so we fail fast + // with a useful error instead of an opaque GraphQL one. + var matched string + for _, o := range field.Options { + if strings.EqualFold(o.Name, rf.Value) { + matched = o.Name + break + } + } + if matched == "" { + optionNames := make([]string, 0, len(field.Options)) + for _, o := range field.Options { + optionNames = append(optionNames, o.Name) + } + return nil, fmt.Errorf("field_filters: %q is not a valid option for %q. Valid options: %s", rf.Value, field.Name, strings.Join(optionNames, ", ")) + } + v := githubv4.String(matched) + filter.SingleSelectOptionValue = &v + case "TEXT": + v := githubv4.String(rf.Value) + filter.TextValue = &v + case "DATE": + if _, err := time.Parse("2006-01-02", rf.Value); err != nil { + return nil, fmt.Errorf("field_filters: %q is not a valid date for %q (expected YYYY-MM-DD): %s", rf.Value, field.Name, err.Error()) + } + v := githubv4.String(rf.Value) + filter.DateValue = &v + case "NUMBER": + n, err := strconv.ParseFloat(rf.Value, 64) + if err != nil { + return nil, fmt.Errorf("field_filters: %q is not a valid number for %q: %s", rf.Value, field.Name, err.Error()) + } + v := githubv4.Float(n) + filter.NumberValue = &v + default: + return nil, fmt.Errorf("field_filters: field %q has unsupported data_type %q", field.Name, field.DataType) + } + out = append(out, filter) + } + return out, nil } // parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go new file mode 100644 index 0000000000..22d26cc47f --- /dev/null +++ b/pkg/github/issues_granular.go @@ -0,0 +1,1189 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "strings" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +// issueUpdateTool is a helper to create single-field issue update tools. +func issueUpdateTool( + t translations.TranslationHelperFunc, + name, description, title string, + extraProps map[string]*jsonschema.Schema, + extraRequired []string, + buildRequest func(args map[string]any) (*github.IssueRequest, error), +) inventory.ServerTool { + props := map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "The issue number to update", + Minimum: jsonschema.Ptr(1.0), + }, + } + maps.Copy(props, extraProps) + + required := append([]string{"owner", "repo", "issue_number"}, extraRequired...) + + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: name, + Description: t("TOOL_"+strings.ToUpper(name)+"_DESCRIPTION", description), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_"+strings.ToUpper(name)+"_USER_TITLE", title), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: props, + Required: required, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + issueReq, err := buildRequest(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + issue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueReq) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%d", issue.GetID()), + URL: issue.GetHTMLURL(), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st +} + +// GranularCreateIssue creates a tool to create a new issue. +func GranularCreateIssue(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "create_issue", + Description: t("TOOL_CREATE_ISSUE_DESCRIPTION", "Create a new issue in a GitHub repository with a title and optional body."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_ISSUE_USER_TITLE", "Create Issue"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "title": { + Type: "string", + Description: "Issue title", + }, + "body": { + Type: "string", + Description: "Issue body content (optional)", + }, + }, + Required: []string{"owner", "repo", "title"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + title, err := RequiredParam[string](args, "title") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, _ := OptionalParam[string](args, "body") + + issueReq := &github.IssueRequest{ + Title: &title, + } + if body != "" { + issueReq.Body = &body + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + issue, resp, err := client.Issues.Create(ctx, owner, repo, issueReq) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create issue", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%d", issue.GetID()), + URL: issue.GetHTMLURL(), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st +} + +// GranularUpdateIssueTitle creates a tool to update an issue's title. +func GranularUpdateIssueTitle(t translations.TranslationHelperFunc) inventory.ServerTool { + return issueUpdateTool(t, + "update_issue_title", + "Update the title of an existing issue.", + "Update Issue Title", + map[string]*jsonschema.Schema{ + "title": {Type: "string", Description: "The new title for the issue"}, + }, + []string{"title"}, + func(args map[string]any) (*github.IssueRequest, error) { + title, err := RequiredParam[string](args, "title") + if err != nil { + return nil, err + } + return &github.IssueRequest{Title: &title}, nil + }, + ) +} + +// GranularUpdateIssueBody creates a tool to update an issue's body. +func GranularUpdateIssueBody(t translations.TranslationHelperFunc) inventory.ServerTool { + return issueUpdateTool(t, + "update_issue_body", + "Update the body content of an existing issue.", + "Update Issue Body", + map[string]*jsonschema.Schema{ + "body": {Type: "string", Description: "The new body content for the issue"}, + }, + []string{"body"}, + func(args map[string]any) (*github.IssueRequest, error) { + body, err := RequiredParam[string](args, "body") + if err != nil { + return nil, err + } + return &github.IssueRequest{Body: &body}, nil + }, + ) +} + +// GranularUpdateIssueAssignees creates a tool to update an issue's assignees. +func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventory.ServerTool { + return issueUpdateTool(t, + "update_issue_assignees", + "Update the assignees of an existing issue. This replaces the current assignees with the provided list.", + "Update Issue Assignees", + map[string]*jsonschema.Schema{ + "assignees": { + Type: "array", + Description: "GitHub usernames to assign to this issue", + Items: &jsonschema.Schema{Type: "string"}, + }, + }, + []string{"assignees"}, + func(args map[string]any) (*github.IssueRequest, error) { + if _, ok := args["assignees"]; !ok { + return nil, fmt.Errorf("missing required parameter: assignees") + } + assignees, err := OptionalStringArrayParam(args, "assignees") + if err != nil { + return nil, err + } + return &github.IssueRequest{Assignees: &assignees}, nil + }, + ) +} + +// labelWithIntent represents the object form of a label entry, allowing a +// rationale, confidence level, and/or suggest flag to be sent alongside the label name. +type labelWithIntent struct { + Name string `json:"name"` + Rationale string `json:"rationale,omitempty"` + Confidence string `json:"confidence,omitempty"` + Suggest bool `json:"suggest,omitempty"` +} + +// labelsUpdateRequest is a custom request body for updating an issue's labels +// where individual labels may optionally include a rationale. Each element of +// Labels is either a string (label name) or a labelWithIntent object. +type labelsUpdateRequest struct { + Labels []any `json:"labels"` +} + +// GranularUpdateIssueLabels creates a tool to update an issue's labels. +func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "update_issue_labels", + Description: t("TOOL_UPDATE_ISSUE_LABELS_DESCRIPTION", "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_ISSUE_LABELS_USER_TITLE", "Update Issue Labels"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "The issue number to update", + Minimum: jsonschema.Ptr(1.0), + }, + "labels": { + Type: "array", + Description: "Labels to apply to this issue.", + Items: &jsonschema.Schema{ + OneOf: []*jsonschema.Schema{ + {Type: "string", Description: "Label name"}, + { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "Label name", + }, + "rationale": { + Type: "string", + Description: "One concise sentence explaining what specifically about the issue led you to choose this label. " + + "State the concrete signal (e.g. 'Reports a crash when saving' → bug).", + MaxLength: jsonschema.Ptr(280), + }, + "confidence": { + Type: "string", + Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", + Enum: []any{"low", "medium", "high"}, + }, + "is_suggestion": { + Type: "boolean", + Description: "If true, this label is sent to the API as a suggestion (suggest:true) rather than an applied label. " + + "Whether the label is applied or recorded as a proposal is determined by the API.", + }, + }, + Required: []string{"name"}, + }, + }, + }, + }, + }, + Required: []string{"owner", "repo", "issue_number", "labels"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + labelsRaw, ok := args["labels"] + if !ok { + return utils.NewToolResultError("missing required parameter: labels"), nil, nil + } + labelsSlice, ok := labelsRaw.([]any) + if !ok { + // Also accept []string for callers that pre-typed the array. + if strs, ok := labelsRaw.([]string); ok { + labelsSlice = make([]any, len(strs)) + for i, s := range strs { + labelsSlice[i] = s + } + } else { + return utils.NewToolResultError("parameter labels must be an array"), nil, nil + } + } + + useObjectForm := false + payload := make([]any, 0, len(labelsSlice)) + for _, item := range labelsSlice { + switch v := item.(type) { + case string: + payload = append(payload, v) + case map[string]any: + name, err := RequiredParam[string](v, "name") + if err != nil { + return utils.NewToolResultError("each label object must have a 'name' string"), nil, nil + } + rationale, err := OptionalParam[string](v, "rationale") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + rationale = strings.TrimSpace(rationale) + if len([]rune(rationale)) > 280 { + return utils.NewToolResultError("label rationale must be 280 characters or less"), nil, nil + } + confidence, err := OptionalParam[string](v, "confidence") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" { + return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil + } + isSuggestion, err := OptionalParam[bool](v, "is_suggestion") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if rationale == "" && !isSuggestion && confidence == "" { + payload = append(payload, name) + } else { + useObjectForm = true + payload = append(payload, labelWithIntent{Name: name, Rationale: rationale, Confidence: confidence, Suggest: isSuggestion}) + } + default: + return utils.NewToolResultError("each label must be a string or an object with 'name' and optional 'rationale', 'confidence', and/or 'is_suggestion'"), nil, nil + } + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + var body any + if useObjectForm { + body = &labelsUpdateRequest{Labels: payload} + } else { + // Preserve the standard wire format when no rationale or suggest is supplied. + names := make([]string, len(payload)) + for i, p := range payload { + names[i] = p.(string) + } + body = &github.IssueRequest{Labels: &names} + } + + apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber) + req, err := client.NewRequest(ctx, "PATCH", apiURL, body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + } + + issue := &github.Issue{} + resp, err := client.Do(req, issue) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%d", issue.GetID()), + URL: issue.GetHTMLURL(), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st +} + +// GranularUpdateIssueMilestone creates a tool to update an issue's milestone. +func GranularUpdateIssueMilestone(t translations.TranslationHelperFunc) inventory.ServerTool { + return issueUpdateTool(t, + "update_issue_milestone", + "Update the milestone of an existing issue.", + "Update Issue Milestone", + map[string]*jsonschema.Schema{ + "milestone": { + Type: "integer", + Description: "The milestone number to set on the issue", + Minimum: jsonschema.Ptr(1.0), + }, + }, + []string{"milestone"}, + func(args map[string]any) (*github.IssueRequest, error) { + milestone, err := RequiredInt(args, "milestone") + if err != nil { + return nil, err + } + return &github.IssueRequest{Milestone: &milestone}, nil + }, + ) +} + +// issueTypeWithIntent represents the object form of the issue type field, +// allowing a rationale, confidence level, and/or suggest flag to be sent alongside the type name. +type issueTypeWithIntent struct { + Value string `json:"value"` + Rationale string `json:"rationale,omitempty"` + Confidence string `json:"confidence,omitempty"` + Suggest bool `json:"suggest,omitempty"` +} + +// issueTypeUpdateRequest is a custom request body for updating an issue type +// with optional intent metadata, using the object form that the REST API accepts. +type issueTypeUpdateRequest struct { + Type issueTypeWithIntent `json:"type"` +} + +// GranularUpdateIssueType creates a tool to update an issue's type. +func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "update_issue_type", + Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_ISSUE_TYPE_USER_TITLE", "Update Issue Type"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "The issue number to update", + Minimum: jsonschema.Ptr(1.0), + }, + "issue_type": { + Type: "string", + Description: "The issue type to set", + }, + "rationale": { + Type: "string", + Description: "One concise sentence explaining what specifically about the issue led you to choose this type. " + + "State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature).", + MaxLength: jsonschema.Ptr(280), + }, + "confidence": { + Type: "string", + Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", + Enum: []any{"low", "medium", "high"}, + }, + "is_suggestion": { + Type: "boolean", + Description: "If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. " + + "Whether the type is applied or recorded as a proposal is determined by the API.", + }, + }, + Required: []string{"owner", "repo", "issue_number", "issue_type"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueType, err := RequiredParam[string](args, "issue_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + rationale, err := OptionalParam[string](args, "rationale") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + rationale = strings.TrimSpace(rationale) + if len([]rune(rationale)) > 280 { + return utils.NewToolResultError("parameter rationale must be 280 characters or less"), nil, nil + } + confidence, err := OptionalParam[string](args, "confidence") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" { + return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil + } + isSuggestion, err := OptionalParam[bool](args, "is_suggestion") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + var body any + if rationale != "" || isSuggestion || confidence != "" { + body = &issueTypeUpdateRequest{ + Type: issueTypeWithIntent{ + Value: issueType, + Rationale: rationale, + Confidence: confidence, + Suggest: isSuggestion, + }, + } + } else { + body = &github.IssueRequest{Type: &issueType} + } + + apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber) + req, err := client.NewRequest(ctx, "PATCH", apiURL, body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + } + + issue := &github.Issue{} + resp, err := client.Do(req, issue) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%d", issue.GetID()), + URL: issue.GetHTMLURL(), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st +} + +// GranularUpdateIssueState creates a tool to update an issue's state. +func GranularUpdateIssueState(t translations.TranslationHelperFunc) inventory.ServerTool { + return issueUpdateTool(t, + "update_issue_state", + "Update the state of an existing issue (open or closed), with an optional state reason.", + "Update Issue State", + map[string]*jsonschema.Schema{ + "state": { + Type: "string", + Description: "The new state for the issue", + Enum: []any{"open", "closed"}, + }, + "state_reason": { + Type: "string", + Description: "The reason for the state change (only for closed state)", + Enum: []any{"completed", "not_planned", "duplicate"}, + }, + }, + []string{"state"}, + func(args map[string]any) (*github.IssueRequest, error) { + state, err := RequiredParam[string](args, "state") + if err != nil { + return nil, err + } + req := &github.IssueRequest{State: &state} + + stateReason, _ := OptionalParam[string](args, "state_reason") + if stateReason != "" { + req.StateReason = &stateReason + } + return req, nil + }, + ) +} + +// GranularAddSubIssue creates a tool to add a sub-issue. +func GranularAddSubIssue(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "add_sub_issue", + Description: t("TOOL_ADD_SUB_ISSUE_DESCRIPTION", "Add a sub-issue to a parent issue."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ADD_SUB_ISSUE_USER_TITLE", "Add Sub-Issue"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "The parent issue number", + Minimum: jsonschema.Ptr(1.0), + }, + "sub_issue_id": { + Type: "number", + Description: "The ID of the sub-issue to add. ID is not the same as issue number", + }, + "replace_parent": { + Type: "boolean", + Description: "If true, reparent the sub-issue if it already has a parent", + }, + }, + Required: []string{"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) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + subIssueID, err := RequiredInt(args, "sub_issue_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + replaceParent, _ := OptionalParam[bool](args, "replace_parent") + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + result, err := AddSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, replaceParent) + return result, nil, err + }, + ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st +} + +// GranularRemoveSubIssue creates a tool to remove a sub-issue. +func GranularRemoveSubIssue(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "remove_sub_issue", + Description: t("TOOL_REMOVE_SUB_ISSUE_DESCRIPTION", "Remove a sub-issue from a parent issue."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_REMOVE_SUB_ISSUE_USER_TITLE", "Remove Sub-Issue"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "The parent issue number", + Minimum: jsonschema.Ptr(1.0), + }, + "sub_issue_id": { + Type: "number", + Description: "The ID of the sub-issue to remove. ID is not the same as issue number", + }, + }, + Required: []string{"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) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + subIssueID, err := RequiredInt(args, "sub_issue_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + result, err := RemoveSubIssue(ctx, client, owner, repo, issueNumber, subIssueID) + return result, nil, err + }, + ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st +} + +// GranularReprioritizeSubIssue creates a tool to reorder a sub-issue. +func GranularReprioritizeSubIssue(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "reprioritize_sub_issue", + Description: t("TOOL_REPRIORITIZE_SUB_ISSUE_DESCRIPTION", "Reprioritize (reorder) a sub-issue relative to other sub-issues."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_REPRIORITIZE_SUB_ISSUE_USER_TITLE", "Reprioritize Sub-Issue"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "The parent issue number", + Minimum: jsonschema.Ptr(1.0), + }, + "sub_issue_id": { + Type: "number", + Description: "The ID of the sub-issue to reorder. ID is not the same as issue number", + }, + "after_id": { + Type: "number", + Description: "The ID of the sub-issue to place this after (either after_id OR before_id should be specified)", + }, + "before_id": { + Type: "number", + Description: "The ID of the sub-issue to place this before (either after_id OR before_id should be specified)", + }, + }, + Required: []string{"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) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + subIssueID, err := RequiredInt(args, "sub_issue_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + afterID, err := OptionalIntParam(args, "after_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + beforeID, err := OptionalIntParam(args, "before_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + result, err := ReprioritizeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, afterID, beforeID) + return result, nil, err + }, + ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st +} + +// SetIssueFieldValueInput represents the input for the setIssueFieldValue GraphQL mutation. +type SetIssueFieldValueInput struct { + IssueID githubv4.ID `json:"issueId"` + IssueFields []IssueFieldCreateOrUpdateInput `json:"issueFields"` + ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"` +} + +// IssueFieldCreateOrUpdateInput represents a single field value to set on an issue. +type IssueFieldCreateOrUpdateInput struct { + FieldID githubv4.ID `json:"fieldId"` + TextValue *githubv4.String `json:"textValue,omitempty"` + NumberValue *githubv4.Float `json:"numberValue,omitempty"` + DateValue *githubv4.String `json:"dateValue,omitempty"` + SingleSelectOptionID *githubv4.ID `json:"singleSelectOptionId,omitempty"` + Delete *githubv4.Boolean `json:"delete,omitempty"` + Rationale *githubv4.String `json:"rationale,omitempty"` + Confidence *string `json:"confidence,omitempty"` + Suggest *githubv4.Boolean `json:"suggest,omitempty"` +} + +// GranularSetIssueFields creates a tool to set issue field values on an issue using GraphQL. +func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "set_issue_fields", + Description: t("TOOL_SET_ISSUE_FIELDS_DESCRIPTION", "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_SET_ISSUE_FIELDS_USER_TITLE", "Set Issue Fields"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "The issue number to update", + Minimum: jsonschema.Ptr(1.0), + }, + "fields": { + Type: "array", + Description: "Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, or 'single_select_option_id' (the GraphQL node ID of the option) for single select fields. Set 'delete' to true to remove a field value.", + MinItems: jsonschema.Ptr(1), + Items: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "field_id": { + Type: "string", + Description: "The GraphQL node ID of the issue field", + }, + "text_value": { + Type: "string", + Description: "The value to set for a text field", + }, + "number_value": { + Type: "number", + Description: "The value to set for a number field", + }, + "date_value": { + Type: "string", + Description: "The value to set for a date field (ISO 8601 date string)", + }, + "single_select_option_id": { + Type: "string", + Description: "The GraphQL node ID of the option to set for a single select field", + }, + "delete": { + Type: "boolean", + Description: "Set to true to delete this field value", + }, + "rationale": { + Type: "string", + Description: "One concise sentence explaining what specifically about the issue led you to choose this field value. " + + "State the concrete signal (e.g. 'Reports a crash when saving' → high priority).", + MaxLength: jsonschema.Ptr(280), + }, + "confidence": { + Type: "string", + Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", + Enum: []any{"low", "medium", "high"}, + }, + "is_suggestion": { + Type: "boolean", + Description: "If true, this field value is sent to the API as a suggestion (suggest:true) rather than an applied value. " + + "Whether the value is applied or recorded as a proposal is determined by the API.", + }, + }, + Required: []string{"field_id"}, + }, + }, + }, + Required: []string{"owner", "repo", "issue_number", "fields"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + fieldsRaw, ok := args["fields"] + if !ok { + return utils.NewToolResultError("missing required parameter: fields"), nil, nil + } + + // Accept both []any and []map[string]any input forms + var fieldMaps []map[string]any + switch v := fieldsRaw.(type) { + case []any: + for _, f := range v { + fieldMap, ok := f.(map[string]any) + if !ok { + return utils.NewToolResultError("each field must be an object with 'field_id' and a value"), nil, nil + } + fieldMaps = append(fieldMaps, fieldMap) + } + case []map[string]any: + fieldMaps = v + default: + return utils.NewToolResultError("invalid parameter: fields must be an array"), nil, nil + } + if len(fieldMaps) == 0 { + return utils.NewToolResultError("fields array must not be empty"), nil, nil + } + + issueFields := make([]IssueFieldCreateOrUpdateInput, 0, len(fieldMaps)) + for _, fieldMap := range fieldMaps { + fieldID, err := RequiredParam[string](fieldMap, "field_id") + if err != nil { + return utils.NewToolResultError("field_id is required and must be a string"), nil, nil + } + + input := IssueFieldCreateOrUpdateInput{ + FieldID: githubv4.ID(fieldID), + } + + // Count how many value keys are present; exactly one is required. + valueCount := 0 + + if v, err := OptionalParam[string](fieldMap, "text_value"); err == nil && v != "" { + input.TextValue = githubv4.NewString(githubv4.String(v)) + valueCount++ + } + if v, err := OptionalParam[float64](fieldMap, "number_value"); err == nil { + if _, exists := fieldMap["number_value"]; exists { + gqlFloat := githubv4.Float(v) + input.NumberValue = &gqlFloat + valueCount++ + } + } + if v, err := OptionalParam[string](fieldMap, "date_value"); err == nil && v != "" { + input.DateValue = githubv4.NewString(githubv4.String(v)) + valueCount++ + } + if v, err := OptionalParam[string](fieldMap, "single_select_option_id"); err == nil && v != "" { + optionID := githubv4.ID(v) + input.SingleSelectOptionID = &optionID + valueCount++ + } + if _, exists := fieldMap["delete"]; exists { + del, err := OptionalParam[bool](fieldMap, "delete") + if err == nil && del { + deleteVal := githubv4.Boolean(true) + input.Delete = &deleteVal + valueCount++ + } + } + + if valueCount == 0 { + return utils.NewToolResultError("each field must have a value (text_value, number_value, date_value, single_select_option_id) or delete: true"), nil, nil + } + if valueCount > 1 { + return utils.NewToolResultError("each field must have exactly one value (text_value, number_value, date_value, single_select_option_id) or delete: true, but multiple were provided"), nil, nil + } + + if _, exists := fieldMap["rationale"]; exists { + rationale, err := OptionalParam[string](fieldMap, "rationale") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + rationale = strings.TrimSpace(rationale) + if len([]rune(rationale)) > 280 { + return utils.NewToolResultError("field rationale must be 280 characters or less"), nil, nil + } + if rationale != "" { + input.Rationale = githubv4.NewString(githubv4.String(rationale)) + } + } + + confidence, err := OptionalParam[string](fieldMap, "confidence") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" { + return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil + } + if confidence != "" { + input.Confidence = &confidence + } + + isSuggestion, err := OptionalParam[bool](fieldMap, "is_suggestion") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if isSuggestion { + suggestVal := githubv4.Boolean(true) + input.Suggest = &suggestVal + } + + issueFields = append(issueFields, input) + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + + // Resolve issue node ID + issueID, _, err := fetchIssueIDs(ctx, gqlClient, owner, repo, issueNumber, 0) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue", err), nil, nil + } + + // Execute the setIssueFieldValue mutation + var mutation struct { + SetIssueFieldValue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + IssueFieldValues []struct { + TextValue struct { + Value string + } `graphql:"... on IssueFieldTextValue"` + SingleSelectValue struct { + Name string + } `graphql:"... on IssueFieldSingleSelectValue"` + DateValue struct { + Value string + } `graphql:"... on IssueFieldDateValue"` + NumberValue struct { + Value float64 + } `graphql:"... on IssueFieldNumberValue"` + } + } `graphql:"setIssueFieldValue(input: $input)"` + } + + mutationInput := SetIssueFieldValueInput{ + IssueID: issueID, + IssueFields: issueFields, + } + + if err := gqlClient.Mutate(ctx, &mutation, mutationInput, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to set issue field values", err), nil, nil + } + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", mutation.SetIssueFieldValue.Issue.ID), + URL: string(mutation.SetIssueFieldValue.Issue.URL), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st +} diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index d06721be72..03bf4275f5 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -13,9 +13,10 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/lockdown" + "github.com/github/github-mcp-server/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/pkg/http/transport" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -23,17 +24,14 @@ import ( ) var defaultGQLClient *githubv4.Client = githubv4.NewClient(newRepoAccessHTTPClient()) -var repoAccessCache *lockdown.RepoAccessCache = stubRepoAccessCache(defaultGQLClient, 15*time.Minute) type repoAccessKey struct { - owner string - repo string - username string + owner string + repo string } type repoAccessValue struct { - isPrivate bool - permission string + isPrivate bool } type repoAccessMockTransport struct { @@ -42,8 +40,8 @@ type repoAccessMockTransport struct { func newRepoAccessHTTPClient() *http.Client { responses := map[repoAccessKey]repoAccessValue{ - {owner: "owner2", repo: "repo2", username: "testuser2"}: {isPrivate: true}, - {owner: "owner", repo: "repo", username: "testuser"}: {isPrivate: false, permission: "READ"}, + {owner: "owner2", repo: "repo2"}: {isPrivate: true}, + {owner: "owner", repo: "repo"}: {isPrivate: false}, } return &http.Client{Transport: &repoAccessMockTransport{responses: responses}} @@ -66,33 +64,21 @@ func (rt *repoAccessMockTransport) RoundTrip(req *http.Request) (*http.Response, owner := toString(payload.Variables["owner"]) repo := toString(payload.Variables["name"]) - username := toString(payload.Variables["username"]) - value, ok := rt.responses[repoAccessKey{owner: owner, repo: repo, username: username}] + value, ok := rt.responses[repoAccessKey{owner: owner, repo: repo}] if !ok { - value = repoAccessValue{isPrivate: false, permission: "WRITE"} + value = repoAccessValue{isPrivate: false} } - edges := []any{} - if value.permission != "" { - edges = append(edges, map[string]any{ - "permission": value.permission, - "node": map[string]any{ - "login": username, - }, - }) + data := map[string]any{} + if strings.Contains(payload.Query, "viewer") { + data["viewer"] = map[string]any{"login": "test-viewer"} + } + if strings.Contains(payload.Query, "repository") { + data["repository"] = map[string]any{"isPrivate": value.isPrivate} } - responseBody, err := json.Marshal(map[string]any{ - "data": map[string]any{ - "repository": map[string]any{ - "isPrivate": value.isPrivate, - "collaborators": map[string]any{ - "edges": edges, - }, - }, - }, - }) + responseBody, err := json.Marshal(map[string]any{"data": data}) if err != nil { return nil, err } @@ -170,13 +156,13 @@ func Test_GetIssue(t *testing.T) { tests := []struct { name string mockedClient *http.Client - gqlHTTPClient *http.Client requestArgs map[string]any expectHandlerError bool expectResultError bool expectedIssue *github.Issue expectedErrMsg string lockdownEnabled bool + restPermission string }{ { name: "successful issue retrieval", @@ -210,36 +196,6 @@ func Test_GetIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue2), }), - gqlHTTPClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - IsPrivate githubv4.Boolean - Collaborators struct { - Edges []struct { - Permission githubv4.String - Node struct { - Login githubv4.String - } - } - } `graphql:"collaborators(query: $username, first: 1)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner2"), - "name": githubv4.String("repo2"), - "username": githubv4.String("testuser2"), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "isPrivate": true, - "collaborators": map[string]any{ - "edges": []any{}, - }, - }, - }), - ), - ), requestArgs: map[string]any{ "method": "get", "owner": "owner2", @@ -248,49 +204,13 @@ func Test_GetIssue(t *testing.T) { }, expectedIssue: mockIssue2, lockdownEnabled: true, + restPermission: "none", }, { name: "lockdown enabled - user lacks push access", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), }), - gqlHTTPClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - IsPrivate githubv4.Boolean - Collaborators struct { - Edges []struct { - Permission githubv4.String - Node struct { - Login githubv4.String - } - } - } `graphql:"collaborators(query: $username, first: 1)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "username": githubv4.String("testuser"), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "isPrivate": false, - "collaborators": map[string]any{ - "edges": []any{ - map[string]any{ - "permission": "READ", - "node": map[string]any{ - "login": "testuser", - }, - }, - }, - }, - }, - }), - ), - ), requestArgs: map[string]any{ "method": "get", "owner": "owner", @@ -300,26 +220,24 @@ func Test_GetIssue(t *testing.T) { expectResultError: true, expectedErrMsg: "access to issue details is restricted by lockdown mode", lockdownEnabled: true, + restPermission: "read", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) - var gqlClient *githubv4.Client - cache := repoAccessCache - if tc.gqlHTTPClient != nil { - gqlClient = githubv4.NewClient(tc.gqlHTTPClient) - cache = stubRepoAccessCache(gqlClient, 15*time.Minute) - } else { - gqlClient = githubv4.NewClient(nil) + var restClient *github.Client + if tc.restPermission != "" { + restClient = mockRESTPermissionServer(t, tc.restPermission, nil) } + cache := stubRepoAccessCache(restClient, 15*time.Minute) flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}) deps := BaseDeps{ Client: client, - GQLClient: gqlClient, + GQLClient: defaultGQLClient, RepoAccessCache: cache, Flags: flags, } @@ -358,6 +276,289 @@ func Test_GetIssue(t *testing.T) { } } +func Test_IssueRead_IFC_InsidersMode(t *testing.T) { + t.Parallel() + + serverTool := IssueRead(translations.NullTranslationHelper) + + mockIssue := &github.Issue{ + Number: github.Ptr(1), + Title: github.Ptr("Test"), + Body: github.Ptr("body"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/octocat/repo/issues/1"), + User: &github.User{Login: github.Ptr("u")}, + } + + mockComments := []*github.IssueComment{ + {Body: github.Ptr("hello"), User: &github.User{Login: github.Ptr("u")}}, + } + + makeMockClient := func(isPrivate bool, repoStatus int) *http.Client { + handlers := map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockComments), + } + if repoStatus != 0 && repoStatus != http.StatusOK { + handlers[GetReposByOwnerByRepo] = mockResponse(t, repoStatus, "boom") + } else { + handlers[GetReposByOwnerByRepo] = mockResponse(t, http.StatusOK, map[string]any{ + "name": "repo", + "private": isPrivate, + }) + } + return MockHTTPClientWithHandlers(handlers) + } + + getReq := map[string]any{ + "method": "get", + "owner": "octocat", + "repo": "repo", + "issue_number": float64(1), + } + commentsReq := map[string]any{ + "method": "get_comments", + "owner": "octocat", + "repo": "repo", + "issue_number": float64(1), + } + + t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false, 0)), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(getReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assert.Nil(t, result.Meta) + }) + + t.Run("insiders mode enabled on public repo emits public untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false, 0)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(getReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode enabled on private repo with get_comments emits private untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(true, 0)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(commentsReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false, http.StatusInternalServerError)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(getReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails") + + if result.Meta != nil { + _, hasIFC := result.Meta["ifc"] + assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails") + } + }) +} + +func Test_GetIssue_FieldValues(t *testing.T) { + // Verify that issue_field_values from the REST API are NOT exposed when the + // remote_mcp_issue_fields flag is off. The raw REST format is always cleared; + // enriched field_values are only populated when the flag is on. + serverTool := IssueRead(translations.NullTranslationHelper) + + mockIssueWithFields := &github.Issue{ + Number: github.Ptr(99), + Title: github.Ptr("Issue with field values"), + Body: github.Ptr("body"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/99"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + IssueFieldValues: []*github.IssueFieldValue{ + { + IssueFieldID: 1001, + NodeID: "FV_node_1", + DataType: "single_select", + Value: "High", + SingleSelectOption: &github.IssueFieldValueSingleSelectOption{ + ID: 42, + Name: "High", + Color: "red", + }, + }, + { + IssueFieldID: 1002, + NodeID: "FV_node_2", + DataType: "text", + Value: "some text value", + }, + }, + } + + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssueWithFields), + }) + + cache := stubRepoAccessCache(nil, 15*time.Minute) + flags := stubFeatureFlags(map[string]bool{"lockdown-mode": false}) + deps := BaseDeps{ + Client: mustNewGHClient(t, mockedClient), + GQLClient: defaultGQLClient, + RepoAccessCache: cache, + Flags: flags, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "get", + "owner": "owner", + "repo": "repo", + "issue_number": float64(99), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.NotNil(t, result) + + textContent := getTextResult(t, result) + + var returnedIssue MinimalIssue + err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + require.NoError(t, err) + + // Flag is off: raw REST IssueFieldValues must be cleared, enriched FieldValues absent. + assert.Empty(t, returnedIssue.IssueFieldValues, "raw REST issue_field_values should not be exposed when flag is off") + assert.Empty(t, returnedIssue.FieldValues, "enriched field_values should not be present when flag is off") +} + +func Test_GetIssue_FieldValues_FlagOn(t *testing.T) { + // Verify the enriched field_values are populated via GraphQL when the + // remote_mcp_issue_fields flag is on, and the raw REST issue_field_values + // stays cleared. + serverTool := IssueRead(translations.NullTranslationHelper) + + mockIssueWithFields := &github.Issue{ + Number: github.Ptr(99), + NodeID: github.Ptr("I_node_99"), + Title: github.Ptr("Issue with field values"), + Body: github.Ptr("body"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/99"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + IssueFieldValues: []*github.IssueFieldValue{ + { + IssueFieldID: 1001, + NodeID: "FV_node_1", + DataType: "single_select", + Value: "High", + }, + }, + } + + restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssueWithFields), + }) + + gqlVars := map[string]any{ + "ids": []any{"I_node_99"}, + } + gqlResponse := githubv4mock.DataResponse(map[string]any{ + "nodes": []map[string]any{ + { + "id": "I_node_99", + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{ + { + "__typename": "IssueFieldSingleSelectValue", + "field": map[string]any{"name": "priority"}, + "value": "P1", + }, + { + "__typename": "IssueFieldNumberValue", + "field": map[string]any{"name": "estimate"}, + "valueNumber": 2.5, + }, + }, + }, + }, + }, + }) + + const nodesQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}}}}" + matcher := githubv4mock.NewQueryMatcher(nodesQueryString, gqlVars, gqlResponse) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + + cache := stubRepoAccessCache(nil, 15*time.Minute) + deps := BaseDeps{ + Client: mustNewGHClient(t, restClient), + GQLClient: gqlClient, + RepoAccessCache: cache, + featureChecker: featureCheckerFor(FeatureFlagIssueFields), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "get", + "owner": "owner", + "repo": "repo", + "issue_number": float64(99), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError, "expected result to not be an error") + + textContent := getTextResult(t, result) + + var returnedIssue MinimalIssue + err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + require.NoError(t, err) + + // Raw REST IssueFieldValues is always cleared, even when flag is on. + assert.Empty(t, returnedIssue.IssueFieldValues, "raw REST issue_field_values should not be exposed even when flag is on") + + // Enriched FieldValues comes from the GraphQL nodes() round-trip. + require.Len(t, returnedIssue.FieldValues, 2, "field_values should be populated from GraphQL when flag is on") + assert.Equal(t, "priority", returnedIssue.FieldValues[0].Field) + assert.Equal(t, "P1", returnedIssue.FieldValues[0].Value) + assert.Equal(t, "estimate", returnedIssue.FieldValues[1].Field) + assert.Equal(t, "2.5", returnedIssue.FieldValues[1].Value) +} + func Test_AddIssueComment(t *testing.T) { // Verify tool definition once serverTool := AddIssueComment(translations.NullTranslationHelper) @@ -427,7 +628,7 @@ func Test_AddIssueComment(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -464,7 +665,6 @@ func Test_AddIssueComment(t *testing.T) { require.NoError(t, err) assert.Equal(t, fmt.Sprintf("%d", tc.expectedComment.GetID()), minimalResponse.ID) assert.Equal(t, tc.expectedComment.GetHTMLURL(), minimalResponse.URL) - }) } } @@ -711,6 +911,47 @@ func Test_SearchIssues(t *testing.T) { expectError: false, expectedResult: mockSearchResult, }, + { + name: "query with field. qualifier enables advanced_search", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue field.priority:P1", + "page": "1", + "per_page": "30", + "advanced_search": "true", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + }), + requestArgs: map[string]any{ + "query": "field.priority:P1", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "query without field. qualifier does not set advanced_search", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue is:open", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + }), + requestArgs: map[string]any{ + "query": "is:open", + }, + expectError: false, + expectedResult: mockSearchResult, + }, { name: "search issues fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -730,7 +971,7 @@ func Test_SearchIssues(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -776,73 +1017,336 @@ func Test_SearchIssues(t *testing.T) { } } -func Test_CreateIssue(t *testing.T) { - // Verify tool definition once - serverTool := IssueWrite(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) +func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { + t.Parallel() - assert.Equal(t, "issue_write", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method") - 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, "title") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "body") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "assignees") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "labels") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "milestone") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "type") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo"}) + serverTool := SearchIssues(translations.NullTranslationHelper) - // Setup mock issue for success case - mockIssue := &github.Issue{ - Number: github.Ptr(123), - Title: github.Ptr("Test Issue"), - Body: github.Ptr("This is a test issue"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), - Assignees: []*github.User{{Login: github.Ptr("user1")}, {Login: github.Ptr("user2")}}, - Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("help wanted")}}, - Milestone: &github.Milestone{Number: github.Ptr(5)}, - Type: &github.IssueType{Name: github.Ptr("Bug")}, + makeIssue := func(owner, repo string, number int) *github.Issue { + return &github.Issue{ + Number: github.Ptr(number), + Title: github.Ptr("issue"), + State: github.Ptr("open"), + RepositoryURL: github.Ptr("https://api.github.com/repos/" + owner + "/" + repo), + User: &github.User{Login: github.Ptr("u")}, + } } - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedIssue *github.Issue - expectedErrMsg string - }{ - { - name: "successful issue creation with all fields", - 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]any{ - "method": "create", - "owner": "owner", - "repo": "repo", - "title": "Test Issue", - "body": "This is a test issue", - "assignees": []any{"user1", "user2"}, - "labels": []any{"bug", "help wanted"}, - "milestone": float64(5), - "type": "Bug", + type repoFixture struct { + owner string + repo string + isPrivate bool + repoStatus int + } + + repoHandlers := func(repos []repoFixture) map[string]http.HandlerFunc { + repoByPath := map[string]repoFixture{} + for _, r := range repos { + repoByPath["/repos/"+r.owner+"/"+r.repo] = r + } + return map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: func(w http.ResponseWriter, req *http.Request) { + r, ok := repoByPath[req.URL.Path] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + if r.repoStatus != 0 && r.repoStatus != http.StatusOK { + w.WriteHeader(r.repoStatus) + return + } + body, _ := json.Marshal(map[string]any{ + "name": r.repo, + "private": r.isPrivate, + }) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) }, - expectError: false, - expectedIssue: mockIssue, + } + } + + makeMockClient := func(searchResult *github.IssuesSearchResult, repos []repoFixture) *http.Client { + handlers := repoHandlers(repos) + handlers[GetSearchIssues] = mockResponse(t, http.StatusOK, searchResult) + return MockHTTPClientWithHandlers(handlers) + } + + reqParams := map[string]any{"query": "bug"} + + t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}} + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Nil(t, result.Meta) + }) + + t.Run("insiders mode all public emits public untrusted", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}} + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode mixed public and private emits private untrusted", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{ + makeIssue("octocat", "private-repo", 1), + makeIssue("octocat", "public-repo", 2), + }} + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{ + {owner: "octocat", repo: "private-repo", isPrivate: true}, + {owner: "octocat", repo: "public-repo"}, + })), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "broken", 1)}} + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{ + {owner: "octocat", repo: "broken", repoStatus: http.StatusInternalServerError}, + })), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails") + + if result.Meta != nil { + _, hasIFC := result.Meta["ifc"] + assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails") + } + }) + + t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{}} + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(searchResult, nil)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) +} + +func unmarshalIFC(t *testing.T, ifcLabel any) map[string]any { + t.Helper() + require.NotNil(t, ifcLabel, "ifc label should be present") + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + return ifcMap +} + +func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) { + serverTool := SearchIssues(translations.NullTranslationHelper) + + mockSearchResult := &github.IssuesSearchResult{ + Total: github.Ptr(2), + IncompleteResults: github.Ptr(false), + Issues: []*github.Issue{ + { + Number: github.Ptr(42), + Title: github.Ptr("Bug: Something is broken"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + NodeID: github.Ptr("I_node_42"), + User: &github.User{Login: github.Ptr("user1")}, + }, + { + Number: github.Ptr(43), + Title: github.Ptr("Feature request"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"), + NodeID: github.Ptr("I_node_43"), + User: &github.User{Login: github.Ptr("user2")}, + }, + }, + } + + restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult), + }) + + gqlVars := map[string]any{ + "ids": []any{"I_node_42", "I_node_43"}, + } + gqlResponse := githubv4mock.DataResponse(map[string]any{ + "nodes": []map[string]any{ + { + "id": "I_node_42", + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{ + { + "__typename": "IssueFieldSingleSelectValue", + "field": map[string]any{"name": "priority"}, + "value": "P1", + }, + { + "__typename": "IssueFieldNumberValue", + "field": map[string]any{"name": "estimate"}, + "valueNumber": 2.5, + }, + }, + }, + }, + { + "id": "I_node_43", + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{}, + }, + }, + }, + }) + + const nodesQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}}}}" + matcher := githubv4mock.NewQueryMatcher(nodesQueryString, gqlVars, gqlResponse) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + + deps := BaseDeps{ + Client: mustNewGHClient(t, restClient), + GQLClient: gqlClient, + featureChecker: featureCheckerFor(FeatureFlagIssueFields), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "query": "repo:owner/repo is:open", + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "expected result to not be an error") + + textContent := getTextResult(t, result) + + var response SearchIssuesResponse + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response)) + require.Equal(t, 2, *response.Total) + require.Len(t, response.Items, 2) + assert.Equal(t, 42, *response.Items[0].Number) + assert.Equal(t, []MinimalFieldValue{ + {Field: "priority", Value: "P1"}, + {Field: "estimate", Value: "2.5"}, + }, response.Items[0].FieldValues) + assert.Equal(t, 43, *response.Items[1].Number) + assert.Empty(t, response.Items[1].FieldValues) +} + +func Test_CreateIssue(t *testing.T) { + // Verify tool definition once (flag-enabled variant snap) + serverTool := IssueWrite(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueFields, tool)) + require.Equal(t, FeatureFlagIssueFields, serverTool.FeatureFlagEnable) + + assert.Equal(t, "issue_write", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method") + 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, "title") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "body") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "assignees") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "labels") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "milestone") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "type") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_fields") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo"}) + + // Setup mock issue for success case + mockIssue := &github.Issue{ + Number: github.Ptr(123), + Title: github.Ptr("Test Issue"), + Body: github.Ptr("This is a test issue"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + Assignees: []*github.User{{Login: github.Ptr("user1")}, {Login: github.Ptr("user2")}}, + Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("help wanted")}}, + Milestone: &github.Milestone{Number: github.Ptr(5)}, + Type: &github.IssueType{Name: github.Ptr("Bug")}, + } + + tests := []struct { + name string + mockedClient *http.Client + mockedGQLClient *http.Client + requestArgs map[string]any + expectError bool + expectedIssue *github.Issue + expectedErrMsg string + }{ + { + name: "successful issue creation with all fields", + 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]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test Issue", + "body": "This is a test issue", + "assignees": []any{"user1", "user2"}, + "labels": []any{"bug", "help wanted"}, + "milestone": float64(5), + "type": "Bug", + }, + expectError: false, + expectedIssue: mockIssue, }, { name: "successful issue creation with minimal fields", @@ -869,6 +1373,77 @@ func Test_CreateIssue(t *testing.T) { State: github.Ptr("open"), }, }, + { + name: "successful issue creation with issue fields reconciled by names", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{ + "title": "Issue with fields", + "body": "", + "labels": []any{}, + "assignees": []any{}, + "issue_field_values": []any{ + map[string]any{"field_id": float64(101), "value": "P1"}, + map[string]any{"field_id": float64(102), "value": "Acme"}, + }, + }).andThen( + mockResponse(t, http.StatusCreated, &github.Issue{ + Number: github.Ptr(125), + Title: github.Ptr("Issue with fields"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/125"), + State: github.Ptr("open"), + }), + ), + }), + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + issueFieldWriteMetadataQuery{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "fullDatabaseId": "101", + "name": "Priority", + "dataType": "single_select", + "options": []any{ + map[string]any{"fullDatabaseId": "9001", "name": "P1"}, + }, + }, + map[string]any{ + "__typename": "IssueFieldText", + "fullDatabaseId": "102", + "name": "Customer", + "dataType": "text", + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Issue with fields", + "issue_fields": []any{ + map[string]any{"field_name": "Priority", "field_option_name": "P1"}, + map[string]any{"field_name": "Customer", "value": "Acme"}, + }, + }, + expectError: false, + expectedIssue: &github.Issue{ + Number: github.Ptr(125), + Title: github.Ptr("Issue with fields"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/125"), + State: github.Ptr("open"), + }, + }, { name: "issue creation fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -886,13 +1461,32 @@ func Test_CreateIssue(t *testing.T) { expectError: false, expectedErrMsg: "missing required parameter: title", }, + { + name: "issue_fields rejects both value and field_option_name", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Invalid fields", + "issue_fields": []any{ + map[string]any{"field_name": "Priority", "value": "P1", "field_option_name": "P1"}, + }, + }, + expectError: false, + expectedErrMsg: "cannot specify both value and field_option_name", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) - gqlClient := githubv4.NewClient(nil) + client := mustNewGHClient(t, tc.mockedClient) + gqlHTTPClient := tc.mockedGQLClient + if gqlHTTPClient == nil { + gqlHTTPClient = githubv4mock.NewMockedHTTPClient() + } + gqlClient := githubv4.NewClient(gqlHTTPClient) deps := BaseDeps{ Client: client, GQLClient: gqlClient, @@ -932,9 +1526,9 @@ func Test_CreateIssue(t *testing.T) { } } -// Test_IssueWrite_InsidersMode_UIGate verifies the insiders mode UI gate +// Test_IssueWrite_MCPAppsFeature_UIGate verifies the MCP Apps feature UI gate // behavior: UI clients get a form message, non-UI clients execute directly. -func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { +func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { t.Parallel() mockIssue := &github.Issue{ @@ -945,14 +1539,14 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { serverTool := IssueWrite(translations.NullTranslationHelper) - client := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposIssuesByOwnerByRepo: mockResponse(t, http.StatusCreated, mockIssue), })) deps := BaseDeps{ - Client: client, - GQLClient: githubv4.NewClient(nil), - Flags: FeatureFlags{InsidersMode: true}, + Client: client, + GQLClient: githubv4.NewClient(nil), + featureChecker: featureCheckerFor(MCPAppsFeatureFlag), } handler := serverTool.Handler(deps) @@ -1027,7 +1621,7 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { }) completedReason := IssueClosedStateReasonCompleted - closeClient := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + closeClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), })) closeGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( @@ -1067,9 +1661,9 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { )) closeDeps := BaseDeps{ - Client: closeClient, - GQLClient: closeGQLClient, - Flags: FeatureFlags{InsidersMode: true}, + Client: closeClient, + GQLClient: closeGQLClient, + featureChecker: featureCheckerFor(MCPAppsFeatureFlag), } closeHandler := serverTool.Handler(closeDeps) @@ -1106,13 +1700,137 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { assert.Contains(t, textContent.Text, "Ready to update issue #1", "update without state should show UI form") }) + + t.Run("UI client with issue_fields skips form and executes directly", func(t *testing.T) { + // The MCP App form does not collect or re-send issue_fields, so a call + // carrying them must bypass the form and apply the values directly. + fieldsClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{ + "title": "Issue with fields", + "body": "", + "labels": []any{}, + "assignees": []any{}, + "issue_field_values": []any{ + map[string]any{"field_id": float64(101), "value": "P1"}, + }, + }).andThen( + mockResponse(t, http.StatusCreated, &github.Issue{ + Number: github.Ptr(125), + Title: github.Ptr("Issue with fields"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/125"), + State: github.Ptr("open"), + }), + ), + })) + fieldsGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + issueFieldWriteMetadataQuery{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "fullDatabaseId": "101", + "name": "Priority", + "dataType": "single_select", + "options": []any{ + map[string]any{"fullDatabaseId": "9001", "name": "P1"}, + }, + }, + }, + }, + }, + }), + ), + )) + + fieldsDeps := BaseDeps{ + Client: fieldsClient, + GQLClient: fieldsGQLClient, + featureChecker: featureCheckerFor(MCPAppsFeatureFlag), + } + fieldsHandler := serverTool.Handler(fieldsDeps) + + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Issue with fields", + "issue_fields": []any{ + map[string]any{"field_name": "Priority", "field_option_name": "P1"}, + }, + }) + result, err := fieldsHandler(ContextWithDeps(context.Background(), fieldsDeps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "Ready to create an issue", + "issue_fields should skip UI form") + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/125", + "issue_fields call should execute directly and return issue URL") + }) + + t.Run("UI client with labels skips form and executes directly", func(t *testing.T) { + // The form does not collect labels, so a call carrying them must bypass + // the form rather than silently drop them. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + "labels": []any{"bug"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "Ready to create an issue", + "labels should skip UI form") + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", + "labels call should execute directly and return issue URL") + }) +} + +func Test_issueWriteHasNonFormParams(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args map[string]any + want bool + }{ + {name: "no params", args: map[string]any{}, want: false}, + {name: "only form params", args: map[string]any{"method": "create", "owner": "o", "repo": "r", "title": "t", "body": "b", "issue_number": float64(1), "_ui_submitted": true}, want: false}, + {name: "labels present", args: map[string]any{"title": "t", "labels": []any{"bug"}}, want: true}, + {name: "assignees present", args: map[string]any{"title": "t", "assignees": []any{"octocat"}}, want: true}, + {name: "milestone present", args: map[string]any{"title": "t", "milestone": float64(2)}, want: true}, + {name: "type present", args: map[string]any{"title": "t", "type": "Bug"}, want: true}, + {name: "issue_fields present", args: map[string]any{"issue_fields": []any{map[string]any{"field_name": "Priority"}}}, want: true}, + {name: "state present", args: map[string]any{"state": "closed"}, want: true}, + {name: "state_reason present", args: map[string]any{"state_reason": "completed"}, want: true}, + {name: "duplicate_of present", args: map[string]any{"duplicate_of": float64(7)}, want: true}, + {name: "nil value is ignored", args: map[string]any{"issue_fields": nil}, want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, issueWriteHasNonFormParams(tc.args)) + }) + } } func Test_ListIssues(t *testing.T) { // Verify tool definition serverTool := ListIssues(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueFields, tool)) + require.Equal(t, FeatureFlagIssueFields, serverTool.FeatureFlagEnable) assert.Equal(t, "list_issues", tool.Name) assert.NotEmpty(t, tool.Description) @@ -1130,14 +1848,15 @@ func Test_ListIssues(t *testing.T) { // Mock issues data mockIssuesAll := []map[string]any{ { - "number": 123, - "title": "First Issue", - "body": "This is the first test issue", - "state": "OPEN", - "databaseId": 1001, - "createdAt": "2023-01-01T00:00:00Z", - "updatedAt": "2023-01-01T00:00:00Z", - "author": map[string]any{"login": "user1"}, + "number": 123, + "title": "First Issue", + "body": "This is the first test issue", + "state": "OPEN", + "databaseId": 1001, + "authorAssociation": "MEMBER", + "createdAt": "2023-01-01T00:00:00Z", + "updatedAt": "2023-01-01T00:00:00Z", + "author": map[string]any{"login": "user1"}, "labels": map[string]any{ "nodes": []map[string]any{ {"name": "bug", "id": "label1", "description": "Bug label"}, @@ -1146,16 +1865,26 @@ func Test_ListIssues(t *testing.T) { "comments": map[string]any{ "totalCount": 5, }, + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{ + { + "__typename": "IssueFieldSingleSelectValue", + "field": map[string]any{"name": "priority"}, + "value": "P1", + }, + }, + }, }, { - "number": 456, - "title": "Second Issue", - "body": "This is the second test issue", - "state": "OPEN", - "databaseId": 1002, - "createdAt": "2023-02-01T00:00:00Z", - "updatedAt": "2023-02-01T00:00:00Z", - "author": map[string]any{"login": "user2"}, + "number": 456, + "title": "Second Issue", + "body": "This is the second test issue", + "state": "OPEN", + "databaseId": 1002, + "authorAssociation": "CONTRIBUTOR", + "createdAt": "2023-02-01T00:00:00Z", + "updatedAt": "2023-02-01T00:00:00Z", + "author": map[string]any{"login": "user2"}, "labels": map[string]any{ "nodes": []map[string]any{ {"name": "enhancement", "id": "label2", "description": "Enhancement label"}, @@ -1164,26 +1893,49 @@ func Test_ListIssues(t *testing.T) { "comments": map[string]any{ "totalCount": 3, }, + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{ + { + "__typename": "IssueFieldDateValue", + "field": map[string]any{"name": "due"}, + "value": "2026-06-01", + }, + { + "__typename": "IssueFieldNumberValue", + "field": map[string]any{"name": "estimate"}, + "valueNumber": 2.5, + }, + { + "__typename": "IssueFieldTextValue", + "field": map[string]any{"name": "notes"}, + "value": "needs triage", + }, + }, + }, }, } mockIssuesOpen := []map[string]any{mockIssuesAll[0], mockIssuesAll[1]} mockIssuesClosed := []map[string]any{ { - "number": 789, - "title": "Closed Issue", - "body": "This is a closed issue", - "state": "CLOSED", - "databaseId": 1003, - "createdAt": "2023-03-01T00:00:00Z", - "updatedAt": "2023-03-01T00:00:00Z", - "author": map[string]any{"login": "user3"}, + "number": 789, + "title": "Closed Issue", + "body": "This is a closed issue", + "state": "CLOSED", + "databaseId": 1003, + "authorAssociation": "NONE", + "createdAt": "2023-03-01T00:00:00Z", + "updatedAt": "2023-03-01T00:00:00Z", + "author": map[string]any{"login": "user3"}, "labels": map[string]any{ "nodes": []map[string]any{}, }, "comments": map[string]any{ "totalCount": 1, }, + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{}, + }, }, } @@ -1200,6 +1952,7 @@ func Test_ListIssues(t *testing.T) { }, "totalCount": 2, }, + "isPrivate": false, }, }) @@ -1215,6 +1968,7 @@ func Test_ListIssues(t *testing.T) { }, "totalCount": 2, }, + "isPrivate": false, }, }) @@ -1230,61 +1984,69 @@ func Test_ListIssues(t *testing.T) { }, "totalCount": 1, }, + "isPrivate": false, }, }) mockErrorRepoNotFound := githubv4mock.ErrorResponse("repository not found") - // Variables matching what GraphQL receives after JSON marshaling/unmarshaling + // Variables matching what GraphQL receives after JSON marshaling/unmarshaling. + // issueFieldValues is always sent as an (empty by default) list because the query + // declares the variable unconditionally; the server treats an empty list as no filter. varsListAll := map[string]any{ - "owner": "owner", - "repo": "repo", - "states": []any{"OPEN", "CLOSED"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), + "owner": "owner", + "repo": "repo", + "states": []any{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + "issueFieldValues": []any{}, } varsOpenOnly := map[string]any{ - "owner": "owner", - "repo": "repo", - "states": []any{"OPEN"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), + "owner": "owner", + "repo": "repo", + "states": []any{"OPEN"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + "issueFieldValues": []any{}, } varsClosedOnly := map[string]any{ - "owner": "owner", - "repo": "repo", - "states": []any{"CLOSED"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), + "owner": "owner", + "repo": "repo", + "states": []any{"CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + "issueFieldValues": []any{}, } varsWithLabels := map[string]any{ - "owner": "owner", - "repo": "repo", - "states": []any{"OPEN", "CLOSED"}, - "labels": []any{"bug", "enhancement"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), + "owner": "owner", + "repo": "repo", + "states": []any{"OPEN", "CLOSED"}, + "labels": []any{"bug", "enhancement"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + "issueFieldValues": []any{}, } varsRepoNotFound := map[string]any{ - "owner": "owner", - "repo": "nonexistent-repo", - "states": []any{"OPEN", "CLOSED"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), + "owner": "owner", + "repo": "nonexistent-repo", + "states": []any{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + "issueFieldValues": []any{}, } tests := []struct { @@ -1330,113 +2092,782 @@ func Test_ListIssues(t *testing.T) { "repo": "repo", "state": "CLOSED", }, - expectError: false, - expectedCount: 1, - }, + expectError: false, + expectedCount: 1, + }, + { + name: "filter by labels", + reqParams: map[string]any{ + "owner": "owner", + "repo": "repo", + "labels": []any{"bug", "enhancement"}, + }, + expectError: false, + expectedCount: 2, + }, + { + name: "repository not found error", + reqParams: map[string]any{ + "owner": "owner", + "repo": "nonexistent-repo", + }, + expectError: true, + errContains: "repository not found", + }, + } + + // Define the actual query strings that match the implementation + issueFieldValuesSelection := "issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}" + qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,authorAssociation,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,authorAssociation,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var httpClient *http.Client + + switch tc.name { + case "list all issues": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsListAll, mockResponseListAll) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by open state": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by open state - lc": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by closed state": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsClosedOnly, mockResponseClosedOnly) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by labels": + matcher := githubv4mock.NewQueryMatcher(qWithLabels, varsWithLabels, mockResponseListAll) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "repository not found error": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsRepoNotFound, mockErrorRepoNotFound) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + } + + gqlClient := githubv4.NewClient(httpClient) + deps := BaseDeps{ + GQLClient: gqlClient, + } + handler := serverTool.Handler(deps) + + req := createMCPRequest(tc.reqParams) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + text := getTextResult(t, res).Text + + if tc.expectError { + require.True(t, res.IsError) + assert.Contains(t, text, tc.errContains) + return + } + require.NoError(t, err) + + // Parse the structured response with pagination info + var response MinimalIssuesResponse + err = json.Unmarshal([]byte(text), &response) + require.NoError(t, err) + + assert.Len(t, response.Issues, tc.expectedCount, "Expected %d issues, got %d", tc.expectedCount, len(response.Issues)) + + // Verify pagination metadata + assert.Equal(t, tc.expectedCount, response.TotalCount) + assert.False(t, response.PageInfo.HasNextPage) + assert.False(t, response.PageInfo.HasPreviousPage) + + // Verify that returned issues have expected structure + for _, issue := range response.Issues { + assert.NotZero(t, issue.Number, "Issue should have number") + assert.NotEmpty(t, issue.Title, "Issue should have title") + assert.NotEmpty(t, issue.State, "Issue should have state") + assert.NotEmpty(t, issue.CreatedAt, "Issue should have created_at") + assert.NotEmpty(t, issue.UpdatedAt, "Issue should have updated_at") + assert.NotNil(t, issue.User, "Issue should have user") + assert.NotEmpty(t, issue.User.Login, "Issue user should have login") + assert.Empty(t, issue.HTMLURL, "html_url should be empty (not populated by GraphQL fragment)") + + // Labels should be flattened to name strings + for _, label := range issue.Labels { + assert.NotEmpty(t, label, "Label should be a non-empty string") + } + + // Field values should be flattened to {field, value} pairs. Issue #123 has a + // SingleSelectValue; issue #456 exercises the Date/Number/Text branches + // (including float formatting); #789 has no field values. + switch issue.Number { + case 123: + assert.Equal(t, "MEMBER", issue.AuthorAssociation) + assert.Equal(t, []MinimalFieldValue{{Field: "priority", Value: "P1"}}, issue.FieldValues) + case 456: + assert.Equal(t, "CONTRIBUTOR", issue.AuthorAssociation) + assert.Equal(t, []MinimalFieldValue{ + {Field: "due", Value: "2026-06-01"}, + {Field: "estimate", Value: "2.5"}, + {Field: "notes", Value: "needs triage"}, + }, issue.FieldValues) + case 789: + assert.Equal(t, "NONE", issue.AuthorAssociation) + default: + assert.Empty(t, issue.FieldValues) + } + } + }) + } +} + +func Test_ListIssues_FieldFilters(t *testing.T) { + t.Parallel() + + serverTool := ListIssues(translations.NullTranslationHelper) + + mockIssues := []map[string]any{ + { + "number": 1, + "title": "An issue", + "body": "body", + "state": "OPEN", + "databaseId": 1, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "author": map[string]any{"login": "user1"}, + "labels": map[string]any{"nodes": []map[string]any{}}, + "comments": map[string]any{"totalCount": 0}, + }, + } + + pageInfo := map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + } + + response := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issues": map[string]any{ + "nodes": mockIssues, + "pageInfo": pageInfo, + "totalCount": 1, + }, + "isPrivate": false, + }, + }) + + // Field-lookup matcher used by every subtest that supplies field_filters. + // The handler calls fetchIssueFields(owner, repo) before issuing the issues query. + fieldsResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "id": "IFSS_1", + "name": "Priority", + "dataType": "SINGLE_SELECT", + "visibility": "ALL", + "options": []any{ + map[string]any{"id": "OPT_P1", "name": "P1", "color": "red"}, + map[string]any{"id": "OPT_P2", "name": "P2", "color": "yellow"}, + }, + }, + map[string]any{ + "__typename": "IssueFieldText", + "id": "IFT_1", + "name": "Notes", + "dataType": "TEXT", + "visibility": "ALL", + }, + map[string]any{ + "__typename": "IssueFieldNumber", + "id": "IFN_1", + "name": "Estimate", + "dataType": "NUMBER", + "visibility": "ALL", + }, + map[string]any{ + "__typename": "IssueFieldDate", + "id": "IFD_1", + "name": "Due", + "dataType": "DATE", + "visibility": "ALL", + }, + }, + }, + }, + }) + fieldsMatcher := func() githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + issueFieldsRepoQuery{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + }, + fieldsResponse, + ) + } + + qNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,authorAssociation,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,authorAssociation,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + + baseVars := func() map[string]any { + return map[string]any{ + "owner": "owner", + "repo": "repo", + "states": []any{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + } + + t.Run("single select field filter", func(t *testing.T) { + vars := baseVars() + vars["issueFieldValues"] = []any{ + map[string]any{"fieldName": "Priority", "singleSelectOptionValue": "P1"}, + } + matcher := githubv4mock.NewQueryMatcher(qNoLabels, vars, response) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher)) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "Priority", "value": "P1"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, res.IsError, getTextResult(t, res).Text) + }) + + t.Run("text field filter combined with labels", func(t *testing.T) { + vars := baseVars() + vars["labels"] = []any{"bug"} + vars["issueFieldValues"] = []any{ + map[string]any{"fieldName": "Notes", "textValue": "needs triage"}, + } + matcher := githubv4mock.NewQueryMatcher(qWithLabels, vars, response) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher)) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "labels": []any{"bug"}, + "field_filters": []any{ + map[string]any{"field_name": "Notes", "value": "needs triage"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, res.IsError, getTextResult(t, res).Text) + }) + + t.Run("number and date field filters", func(t *testing.T) { + vars := baseVars() + vars["issueFieldValues"] = []any{ + map[string]any{"fieldName": "Estimate", "numberValue": float64(2.5)}, + map[string]any{"fieldName": "Due", "dateValue": "2026-06-01"}, + } + matcher := githubv4mock.NewQueryMatcher(qNoLabels, vars, response) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher)) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "Estimate", "value": "2.5"}, + map[string]any{"field_name": "Due", "value": "2026-06-01"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, res.IsError, getTextResult(t, res).Text) + }) + + t.Run("number field accepts zero values", func(t *testing.T) { + for _, value := range []string{"0", "0.0"} { + t.Run(value, func(t *testing.T) { + vars := baseVars() + vars["issueFieldValues"] = []any{ + map[string]any{"fieldName": "Estimate", "numberValue": float64(0)}, + } + matcher := githubv4mock.NewQueryMatcher(qNoLabels, vars, response) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher)) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "Estimate", "value": value}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, res.IsError, getTextResult(t, res).Text) + }) + } + }) + + t.Run("validation error when value missing", func(t *testing.T) { + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(githubv4mock.NewQueryMatcher("", nil, response))) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "Priority"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, res.IsError) + text := getTextResult(t, res).Text + assert.Contains(t, text, "field_filters entry") + assert.Contains(t, text, "Priority") + assert.Contains(t, text, "value") + }) + + t.Run("validation error when field_name missing", func(t *testing.T) { + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(githubv4mock.NewQueryMatcher("", nil, response))) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"value": "P1"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, res.IsError) + text := getTextResult(t, res).Text + assert.Contains(t, text, "field_filters entry") + assert.Contains(t, text, "field_name") + }) + + t.Run("error when field is unknown", func(t *testing.T) { + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher())) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "NotARealField", "value": "x"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, res.IsError) + text := getTextResult(t, res).Text + assert.Contains(t, text, "unknown field") + assert.Contains(t, text, "Priority") + }) + + t.Run("error when single-select option is invalid", func(t *testing.T) { + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher())) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "Priority", "value": "P9"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, res.IsError) + text := getTextResult(t, res).Text + assert.Contains(t, text, "not a valid option") + assert.Contains(t, text, "P1") + }) + + t.Run("error when number value is non-numeric", func(t *testing.T) { + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher())) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "Estimate", "value": "not-a-number"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, res.IsError) + assert.Contains(t, getTextResult(t, res).Text, "not a valid number") + }) + + t.Run("error when date value is malformed", func(t *testing.T) { + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher())) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "Due", "value": "06/01/2026"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, res.IsError) + assert.Contains(t, getTextResult(t, res).Text, "not a valid date") + }) + + // Query string fragments for the `since` variants. Built by string concatenation + // because they only differ from the base variants by the variable declaration and + // the filterBy clause. + qNoLabelsWithSince := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$since:DateTime!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since, issueFieldValues: $issueFieldValues})" + qNoLabels[len("query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues})"):] + qLabelsWithSince := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$since:DateTime!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since, issueFieldValues: $issueFieldValues})" + qWithLabels[len("query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues})"):] + + t.Run("field filter with since", func(t *testing.T) { + vars := baseVars() + vars["since"] = "2026-01-01T00:00:00Z" + vars["issueFieldValues"] = []any{ + map[string]any{"fieldName": "Priority", "singleSelectOptionValue": "P1"}, + } + matcher := githubv4mock.NewQueryMatcher(qNoLabelsWithSince, vars, response) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher)) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "since": "2026-01-01T00:00:00Z", + "field_filters": []any{ + map[string]any{"field_name": "Priority", "value": "P1"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, res.IsError, getTextResult(t, res).Text) + }) + + t.Run("field filter with labels and since", func(t *testing.T) { + vars := baseVars() + vars["labels"] = []any{"bug"} + vars["since"] = "2026-01-01T00:00:00Z" + vars["issueFieldValues"] = []any{ + map[string]any{"fieldName": "Priority", "singleSelectOptionValue": "P1"}, + } + matcher := githubv4mock.NewQueryMatcher(qLabelsWithSince, vars, response) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher)) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "labels": []any{"bug"}, + "since": "2026-01-01T00:00:00Z", + "field_filters": []any{ + map[string]any{"field_name": "Priority", "value": "P1"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, res.IsError, getTextResult(t, res).Text) + }) + + t.Run("sends GraphQL-Features: issue_fields, repo_issue_fields header", func(t *testing.T) { + vars := baseVars() + vars["issueFieldValues"] = []any{} + matcher := githubv4mock.NewQueryMatcher(qNoLabels, vars, response) + + // Build a transport chain matching production: GraphQLFeaturesTransport + // wraps a header-capturing spy, which forwards to the mock's RoundTripper. + // This verifies the handler sets the issue_fields context value and the + // transport translates it into the outgoing header. + mockClient := githubv4mock.NewMockedHTTPClient(matcher) + spy := &headerCaptureTransport{inner: mockClient.Transport} + httpClient := &http.Client{ + Transport: &transportpkg.GraphQLFeaturesTransport{Transport: spy}, + } + gqlClient := githubv4.NewClient(httpClient) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo"}) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, res.IsError, getTextResult(t, res).Text) + assert.Equal(t, "issue_fields, repo_issue_fields", spy.captured.Get(headers.GraphQLFeaturesHeader)) + }) +} + +// headerCaptureTransport records the headers of the most recent request that passed +// through it before forwarding to the inner RoundTripper. +type headerCaptureTransport struct { + inner http.RoundTripper + captured http.Header +} + +func (t *headerCaptureTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.captured = req.Header.Clone() + return t.inner.RoundTrip(req) +} + +func Test_ListIssues_IFC_InsidersMode(t *testing.T) { + t.Parallel() + + serverTool := ListIssues(translations.NullTranslationHelper) + + mockIssues := []map[string]any{ { - name: "filter by labels", - reqParams: map[string]any{ - "owner": "owner", - "repo": "repo", - "labels": []any{"bug", "enhancement"}, - }, - expectError: false, - expectedCount: 2, + "number": 1, + "title": "An issue", + "body": "body", + "state": "OPEN", + "databaseId": 1, + "createdAt": "2023-01-01T00:00:00Z", + "updatedAt": "2023-01-01T00:00:00Z", + "author": map[string]any{"login": "user1"}, + "labels": map[string]any{"nodes": []map[string]any{}}, + "comments": map[string]any{"totalCount": 0}, }, - { - name: "repository not found error", - reqParams: map[string]any{ - "owner": "owner", - "repo": "nonexistent-repo", + } + + pageInfo := map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + } + + makeResponse := func(isPrivate bool) githubv4mock.GQLResponse { + return githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issues": map[string]any{ + "nodes": mockIssues, + "pageInfo": pageInfo, + "totalCount": 1, + }, + "isPrivate": isPrivate, }, - expectError: true, - errContains: "repository not found", - }, + }) } - // Define the actual query strings that match the implementation - qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + query := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,authorAssociation,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + + vars := map[string]any{ + "owner": "octocat", + "repo": "hello", + "states": []any{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + "issueFieldValues": []any{}, + } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - var httpClient *http.Client + reqParams := map[string]any{"owner": "octocat", "repo": "hello"} - switch tc.name { - case "list all issues": - matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsListAll, mockResponseListAll) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "filter by open state": - matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "filter by open state - lc": - matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "filter by closed state": - matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsClosedOnly, mockResponseClosedOnly) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "filter by labels": - matcher := githubv4mock.NewQueryMatcher(qWithLabels, varsWithLabels, mockResponseListAll) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "repository not found error": - matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsRepoNotFound, mockErrorRepoNotFound) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - } + t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(false)) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + deps := BaseDeps{ + GQLClient: gqlClient, + } + handler := serverTool.Handler(deps) - gqlClient := githubv4.NewClient(httpClient) - deps := BaseDeps{ - GQLClient: gqlClient, - } - handler := serverTool.Handler(deps) + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) - req := createMCPRequest(tc.reqParams) - res, err := handler(ContextWithDeps(context.Background(), deps), &req) - text := getTextResult(t, res).Text + assert.Nil(t, result.Meta, "result meta should be nil when insiders mode is disabled") + }) - if tc.expectError { - require.True(t, res.IsError) - assert.Contains(t, text, tc.errContains) - return - } - require.NoError(t, err) + t.Run("insiders mode enabled on public repo emits public untrusted label", func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(false)) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + deps := BaseDeps{ + GQLClient: gqlClient, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) - // Parse the structured response with pagination info - var response MinimalIssuesResponse - err = json.Unmarshal([]byte(text), &response) - require.NoError(t, err) + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) - assert.Len(t, response.Issues, tc.expectedCount, "Expected %d issues, got %d", tc.expectedCount, len(response.Issues)) + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") - // Verify pagination metadata - assert.Equal(t, tc.expectedCount, response.TotalCount) - assert.False(t, response.PageInfo.HasNextPage) - assert.False(t, response.PageInfo.HasPreviousPage) + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) - // Verify that returned issues have expected structure - for _, issue := range response.Issues { - assert.NotZero(t, issue.Number, "Issue should have number") - assert.NotEmpty(t, issue.Title, "Issue should have title") - assert.NotEmpty(t, issue.State, "Issue should have state") - assert.NotEmpty(t, issue.CreatedAt, "Issue should have created_at") - assert.NotEmpty(t, issue.UpdatedAt, "Issue should have updated_at") - assert.NotNil(t, issue.User, "Issue should have user") - assert.NotEmpty(t, issue.User.Login, "Issue user should have login") - assert.Empty(t, issue.HTMLURL, "html_url should be empty (not populated by GraphQL fragment)") + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) - // Labels should be flattened to name strings - for _, label := range issue.Labels { - assert.NotEmpty(t, label, "Label should be a non-empty string") - } - } - }) + t.Run("insiders mode enabled on private repo emits private untrusted label", func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(true)) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + deps := BaseDeps{ + GQLClient: gqlClient, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) +} + +func Test_LegacyListIssues_Definition(t *testing.T) { + serverTool := LegacyListIssues(translations.NullTranslationHelper) + tool := serverTool.Tool + + // LegacyListIssues claims the base tool name "list_issues" and produces the + // FeatureFlagIssueFields-disabled schema (no field_filters). It owns the + // canonical list_issues.snap; the FeatureFlagIssueFields-enabled variant + // owns list_issues_ff_.snap. + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.Equal(t, "list_issues", tool.Name) + require.Equal(t, []string{FeatureFlagIssueFields}, serverTool.FeatureFlagDisable) + require.Empty(t, serverTool.FeatureFlagEnable) + + props := tool.InputSchema.(*jsonschema.Schema).Properties + assert.Contains(t, props, "owner") + assert.Contains(t, props, "repo") + assert.Contains(t, props, "state") + assert.Contains(t, props, "labels") + assert.Contains(t, props, "since") + assert.NotContains(t, props, "field_filters", "legacy list_issues must not advertise field_filters") +} + +func Test_LegacyIssueWrite_Definition(t *testing.T) { + serverTool := LegacyIssueWrite(translations.NullTranslationHelper) + tool := serverTool.Tool + + // LegacyIssueWrite owns the canonical issue_write.snap; the + // FeatureFlagIssueFields-enabled variant owns issue_write_ff_.snap. + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.Equal(t, "issue_write", tool.Name) + require.Equal(t, []string{FeatureFlagIssuesGranular, FeatureFlagIssueFields}, serverTool.FeatureFlagDisable) + require.Empty(t, serverTool.FeatureFlagEnable) + + props := tool.InputSchema.(*jsonschema.Schema).Properties + assert.Contains(t, props, "method") + assert.Contains(t, props, "owner") + assert.Contains(t, props, "repo") + assert.NotContains(t, props, "issue_fields", "legacy issue_write must not advertise issue_fields") +} + +func Test_LegacyListIssues_OmitsFieldValuesAndFilters(t *testing.T) { + t.Parallel() + + serverTool := LegacyListIssues(translations.NullTranslationHelper) + + mockIssues := []map[string]any{ + { + "number": 7, + "title": "Legacy issue", + "body": "body", + "state": "OPEN", + "databaseId": 7, + "authorAssociation": "OWNER", + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "author": map[string]any{"login": "octocat"}, + "labels": map[string]any{"nodes": []map[string]any{}}, + "comments": map[string]any{"totalCount": 0}, + }, + } + pageInfo := map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "c1", + "endCursor": "c1", + } + + // The legacy query must NOT reference issueFieldValues (neither in the selection + // set nor in filterBy). The matcher's query string therefore omits both. + const legacyQuery = "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,authorAssociation,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + vars := map[string]any{ + "owner": "owner", + "repo": "repo", + "states": []any{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": nil, } + response := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "isPrivate": false, + "issues": map[string]any{ + "nodes": mockIssues, + "pageInfo": pageInfo, + "totalCount": 1, + }, + }, + }) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(githubv4mock.NewQueryMatcher(legacyQuery, vars, response))) + + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "expected non-error result; got: %v", getTextResult(t, result).Text) + + var resp MinimalIssuesResponse + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &resp)) + require.Len(t, resp.Issues, 1) + assert.Equal(t, 7, resp.Issues[0].Number) + assert.Equal(t, "OWNER", resp.Issues[0].AuthorAssociation) + assert.Nil(t, resp.Issues[0].FieldValues, "legacy list_issues must not return field_values") } func Test_UpdateIssue(t *testing.T) { - // Verify tool definition + // Verify tool definition (flag-enabled variant snap) serverTool := IssueWrite(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueFields, tool)) assert.Equal(t, "issue_write", tool.Name) assert.NotEmpty(t, tool.Description) @@ -1453,6 +2884,7 @@ func Test_UpdateIssue(t *testing.T) { assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state_reason") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "duplicate_of") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_fields") assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo"}) // Mock issues for reuse across test cases @@ -1564,6 +2996,108 @@ func Test_UpdateIssue(t *testing.T) { expectError: false, expectedIssue: mockUpdatedIssue, }, + { + name: "partial update clears labels and assignees", + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ + "labels": []any{}, + "assignees": []any{}, + }).andThen( + mockResponse(t, http.StatusOK, &github.Issue{ + Number: github.Ptr(123), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + }), + ), + }), + mockedGQLClient: githubv4mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "labels": []any{}, + "assignees": []any{}, + }, + expectError: false, + expectedIssue: &github.Issue{ + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + }, + }, + { + name: "partial update with issue fields reconciled by names", + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ + "issue_field_values": []any{ + map[string]any{"field_id": float64(101), "value": "P1"}, + map[string]any{"field_id": float64(102), "value": "Acme"}, + }, + "title": "Updated Title", + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedIssue), + ), + }), + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + // fetch-and-merge: returns no existing fields so the incoming values are used as-is + githubv4mock.NewQueryMatcher( + "query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){issue(number: $number){issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}}}}", + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "issueFieldValues": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + issueFieldWriteMetadataQuery{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "fullDatabaseId": "101", + "name": "Priority", + "dataType": "single_select", + "options": []any{map[string]any{"fullDatabaseId": "9001", "name": "P1"}}, + }, + map[string]any{ + "__typename": "IssueFieldText", + "fullDatabaseId": "102", + "name": "Customer", + "dataType": "text", + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "title": "Updated Title", + "issue_fields": []any{ + map[string]any{"field_name": "Priority", "field_option_name": "P1"}, + map[string]any{"field_name": "Customer", "value": "Acme"}, + }, + }, + expectError: false, + expectedIssue: mockUpdatedIssue, + }, { name: "issue not found when updating non-state fields only", mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -1863,7 +3397,7 @@ func Test_UpdateIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup clients with mocks - restClient := github.NewClient(tc.mockedRESTClient) + restClient := mustNewGHClient(t, tc.mockedRESTClient) gqlClient := githubv4.NewClient(tc.mockedGQLClient) deps := BaseDeps{ Client: restClient, @@ -1908,6 +3442,47 @@ func Test_UpdateIssue(t *testing.T) { } } +func Test_LegacyUpdateIssueClearsLabelsAndAssignees(t *testing.T) { + serverTool := LegacyIssueWrite(translations.NullTranslationHelper) + updatedIssue := &github.Issue{ + Number: github.Ptr(8), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/8"), + } + + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ + "labels": []any{}, + "assignees": []any{}, + }).andThen(mockResponse(t, http.StatusOK, updatedIssue)), + })) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) + deps := BaseDeps{ + Client: client, + GQLClient: gqlClient, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(8), + "labels": []any{}, + "assignees": []any{}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + if result.IsError { + t.Fatalf("Unexpected error result: %s", getErrorResult(t, result).Text) + } + textContent := getTextResult(t, result) + + var updateResp MinimalResponse + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &updateResp)) + assert.Equal(t, updatedIssue.GetHTMLURL(), updateResp.URL) +} + func Test_ParseISOTimestamp(t *testing.T) { tests := []struct { name string @@ -1997,7 +3572,6 @@ func Test_GetIssueComments(t *testing.T) { tests := []struct { name string mockedClient *http.Client - gqlHTTPClient *http.Client requestArgs map[string]any expectError bool expectedComments []*github.IssueComment @@ -2069,7 +3643,6 @@ func Test_GetIssueComments(t *testing.T) { }, }), }), - gqlHTTPClient: newRepoAccessHTTPClient(), requestArgs: map[string]any{ "method": "get_comments", "owner": "owner", @@ -2091,18 +3664,19 @@ func Test_GetIssueComments(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) - var gqlClient *githubv4.Client - if tc.gqlHTTPClient != nil { - gqlClient = githubv4.NewClient(tc.gqlHTTPClient) - } else { - gqlClient = githubv4.NewClient(nil) + client := mustNewGHClient(t, tc.mockedClient) + var restClient *github.Client + if tc.lockdownEnabled { + restClient = mockRESTPermissionServer(t, "read", map[string]string{ + "maintainer": "write", + "testuser": "read", + }) } - cache := stubRepoAccessCache(gqlClient, 15*time.Minute) + cache := stubRepoAccessCache(restClient, 15*time.Minute) flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}) deps := BaseDeps{ Client: client, - GQLClient: gqlClient, + GQLClient: defaultGQLClient, RepoAccessCache: cache, Flags: flags, } @@ -2219,11 +3793,11 @@ func Test_GetIssueLabels(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { gqlClient := githubv4.NewClient(tc.mockedClient) - client := github.NewClient(nil) + client := mustNewGHClient(t, nil) deps := BaseDeps{ Client: client, GQLClient: gqlClient, - RepoAccessCache: stubRepoAccessCache(gqlClient, 15*time.Minute), + RepoAccessCache: stubRepoAccessCache(nil, 15*time.Minute), Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), } handler := serverTool.Handler(deps) @@ -2426,7 +4000,7 @@ func Test_AddSubIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2647,12 +4221,12 @@ func Test_GetSubIssues(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(nil) deps := BaseDeps{ Client: client, GQLClient: gqlClient, - RepoAccessCache: stubRepoAccessCache(gqlClient, 15*time.Minute), + RepoAccessCache: stubRepoAccessCache(nil, 15*time.Minute), Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), } handler := serverTool.Handler(deps) @@ -2866,7 +4440,7 @@ func Test_RemoveSubIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3126,7 +4700,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3242,7 +4816,7 @@ func Test_ListIssueTypes(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/labels.go b/pkg/github/labels.go index 0dbb622d91..e8d8102cbf 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -24,7 +24,7 @@ func GetLabel(t translations.TranslationHelperFunc) inventory.ServerTool { Name: "get_label", Description: t("TOOL_GET_LABEL_DESCRIPTION", "Get a specific label from a repository."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_LABEL_TITLE", "Get a specific label from a repository."), + Title: t("TOOL_GET_LABEL_TITLE", "Get a specific label from a repository"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ @@ -126,7 +126,7 @@ func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { Name: "list_label", Description: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository"), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository."), + Title: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ @@ -217,7 +217,7 @@ func LabelWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Name: "label_write", Description: t("TOOL_LABEL_WRITE_DESCRIPTION", "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LABEL_WRITE_TITLE", "Write operations on repository labels."), + Title: t("TOOL_LABEL_WRITE_TITLE", "Write operations on repository labels"), ReadOnlyHint: false, }, InputSchema: &jsonschema.Schema{ diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index a8757c51c3..0be7a85e88 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -1,9 +1,13 @@ package github import ( + "fmt" + "net/url" + "strconv" + "strings" "time" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/github/github-mcp-server/pkg/sanitize" ) @@ -51,6 +55,31 @@ type MinimalSearchRepositoriesResult struct { Items []MinimalRepository `json:"items"` } +// MinimalDiscussionComment is the trimmed output type for discussion comment objects. +type MinimalDiscussionComment struct { + ID string `json:"id"` + Body string `json:"body"` + IsAnswer bool `json:"isAnswer,omitempty"` + Replies []MinimalDiscussionComment `json:"replies,omitempty"` + ReplyTotalCount int `json:"replyTotalCount,omitempty"` +} + +// MinimalCodeSearchResult is the trimmed output type for code search results. +type MinimalCodeSearchResult struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []MinimalCodeResult `json:"items"` +} + +// MinimalCodeResult is the trimmed output type for a single code search hit. +type MinimalCodeResult struct { + Name string `json:"name"` + Path string `json:"path"` + SHA string `json:"sha"` + Repository string `json:"repository"` + TextMatches []*github.TextMatch `json:"text_matches,omitempty"` +} + // MinimalCommitAuthor represents commit author information. type MinimalCommitAuthor struct { Name string `json:"name,omitempty"` @@ -79,6 +108,7 @@ type MinimalCommitFile struct { Additions int `json:"additions,omitempty"` Deletions int `json:"deletions,omitempty"` Changes int `json:"changes,omitempty"` + Patch string `json:"patch,omitempty"` } // MinimalPRFile represents a file changed in a pull request. @@ -104,6 +134,23 @@ type MinimalCommit struct { Files []MinimalCommitFile `json:"files,omitempty"` } +// MinimalRepoRef is a lightweight reference to a repository, used when a +// result needs to identify which repository it belongs to (for example, in +// cross-repo commit search results). +type MinimalRepoRef struct { + FullName string `json:"full_name"` + HTMLURL string `json:"html_url,omitempty"` + Private bool `json:"private,omitempty"` +} + +// MinimalCommitSearchItem extends MinimalCommit with the containing +// repository, since commit search spans repositories and callers need to +// know which repo each result came from. +type MinimalCommitSearchItem struct { + MinimalCommit + Repository *MinimalRepoRef `json:"repository,omitempty"` +} + // MinimalRelease is the trimmed output type for release objects. type MinimalRelease struct { ID int64 `json:"id"` @@ -138,6 +185,13 @@ type MinimalResponse struct { URL string `json:"url"` } +// MinimalCollaborator is the trimmed output type for repository collaborators. +type MinimalCollaborator struct { + Login string `json:"login"` + ID int64 `json:"id"` + RoleName string `json:"role_name"` +} + type MinimalProject struct { ID *int64 `json:"id,omitempty"` NodeID *string `json:"node_id,omitempty"` @@ -156,6 +210,68 @@ type MinimalProject struct { OwnerType string `json:"owner_type,omitempty"` } +type MinimalProjectItem struct { + ID int64 `json:"id"` + NodeID string `json:"node_id,omitempty"` + ContentType string `json:"content_type,omitempty"` + Content *MinimalProjectItemContent `json:"content,omitempty"` + Fields []MinimalProjectItemFieldValue `json:"fields,omitempty"` + ArchivedAt string `json:"archived_at,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Creator string `json:"creator,omitempty"` +} + +type MinimalProjectItemContent struct { + ID int64 `json:"id,omitempty"` + NodeID string `json:"node_id,omitempty"` + Number int `json:"number,omitempty"` + Title string `json:"title,omitempty"` + State string `json:"state,omitempty"` + StateReason string `json:"state_reason,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + Repository string `json:"repository,omitempty"` + Author string `json:"author,omitempty"` + Assignees []string `json:"assignees,omitempty"` + Labels []string `json:"labels,omitempty"` + Milestone string `json:"milestone,omitempty"` + Comments int `json:"comments,omitempty"` + Draft bool `json:"draft,omitempty"` + Merged bool `json:"merged,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + ClosedAt string `json:"closed_at,omitempty"` + MergedAt string `json:"merged_at,omitempty"` +} + +type MinimalProjectItemFieldValue struct { + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + DataType string `json:"data_type,omitempty"` + Value any `json:"value,omitempty"` +} + +type minimalProjectOptionValue struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Color string `json:"color,omitempty"` +} + +type minimalProjectIterationValue struct { + ID string `json:"id,omitempty"` + Title string `json:"title,omitempty"` + StartDate string `json:"start_date,omitempty"` + Duration int `json:"duration,omitempty"` +} + +type minimalProjectPullRequestRef struct { + Number int `json:"number,omitempty"` + Title string `json:"title,omitempty"` + State string `json:"state,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + Repository string `json:"repository,omitempty"` +} + // MinimalReactions is the trimmed output type for reaction summaries, dropping the API URL. type MinimalReactions struct { TotalCount int `json:"total_count"` @@ -169,28 +285,55 @@ type MinimalReactions struct { Eyes int `json:"eyes"` } +// MinimalIssueFieldValueSingleSelectOption is the trimmed output type for a single-select option of an issue field value. +type MinimalIssueFieldValueSingleSelectOption struct { + ID int64 `json:"id"` + Name string `json:"name"` + Color string `json:"color"` +} + +// MinimalIssueFieldValue is the trimmed output type for a custom field value attached to an issue, +// populated from REST API responses (e.g. get_issue). For GraphQL-sourced field values see MinimalFieldValue. +type MinimalIssueFieldValue struct { + IssueFieldID int64 `json:"issue_field_id,omitempty"` + NodeID string `json:"node_id,omitempty"` + DataType string `json:"data_type,omitempty"` + Value any `json:"value,omitempty"` + SingleSelectOption *MinimalIssueFieldValueSingleSelectOption `json:"single_select_option,omitempty"` +} + +// MinimalFieldValue is the trimmed output type for a custom field value resolved via GraphQL +// (e.g. list_issues, search_issues). Single-value variants populate Value; Values is reserved for multi-select. +type MinimalFieldValue struct { + Field string `json:"field"` + Value string `json:"value,omitempty"` + Values []string `json:"values,omitempty"` +} + // MinimalIssue is the trimmed output type for issue objects to reduce verbosity. type MinimalIssue struct { - Number int `json:"number"` - Title string `json:"title"` - Body string `json:"body,omitempty"` - State string `json:"state"` - StateReason string `json:"state_reason,omitempty"` - Draft bool `json:"draft,omitempty"` - Locked bool `json:"locked,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - User *MinimalUser `json:"user,omitempty"` - AuthorAssociation string `json:"author_association,omitempty"` - Labels []string `json:"labels,omitempty"` - Assignees []string `json:"assignees,omitempty"` - Milestone string `json:"milestone,omitempty"` - Comments int `json:"comments,omitempty"` - Reactions *MinimalReactions `json:"reactions,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` - ClosedAt string `json:"closed_at,omitempty"` - ClosedBy string `json:"closed_by,omitempty"` - IssueType string `json:"issue_type,omitempty"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + State string `json:"state"` + StateReason string `json:"state_reason,omitempty"` + Draft bool `json:"draft,omitempty"` + Locked bool `json:"locked,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + User *MinimalUser `json:"user,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees,omitempty"` + Milestone string `json:"milestone,omitempty"` + Comments int `json:"comments,omitempty"` + Reactions *MinimalReactions `json:"reactions,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + ClosedAt string `json:"closed_at,omitempty"` + ClosedBy string `json:"closed_by,omitempty"` + IssueType string `json:"issue_type,omitempty"` + IssueFieldValues []MinimalIssueFieldValue `json:"issue_field_values,omitempty"` + FieldValues []MinimalFieldValue `json:"field_values,omitempty"` } // MinimalIssuesResponse is the trimmed output for a paginated list of issues. @@ -212,6 +355,13 @@ type MinimalIssueComment struct { UpdatedAt string `json:"updated_at,omitempty"` } +// MinimalSearchCommitsResult is the trimmed output type for commit search results. +type MinimalSearchCommitsResult struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []MinimalCommitSearchItem `json:"items"` +} + // MinimalFileContentResponse is the trimmed output type for create/update/delete file responses. type MinimalFileContentResponse struct { Content *MinimalFileContent `json:"content,omitempty"` @@ -246,6 +396,7 @@ type MinimalPullRequest struct { MergeableState string `json:"mergeable_state,omitempty"` HTMLURL string `json:"html_url"` User *MinimalUser `json:"user,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` Labels []string `json:"labels,omitempty"` Assignees []string `json:"assignees,omitempty"` RequestedReviewers []string `json:"requested_reviewers,omitempty"` @@ -368,6 +519,26 @@ func convertToMinimalIssue(issue *github.Issue) MinimalIssue { m.IssueType = issueType.GetName() } + for _, fv := range issue.IssueFieldValues { + if fv == nil { + continue + } + mfv := MinimalIssueFieldValue{ + IssueFieldID: fv.IssueFieldID, + NodeID: fv.NodeID, + DataType: fv.DataType, + Value: fv.Value, + } + if opt := fv.SingleSelectOption; opt != nil { + mfv.SingleSelectOption = &MinimalIssueFieldValueSingleSelectOption{ + ID: opt.ID, + Name: opt.Name, + Color: opt.Color, + } + } + m.IssueFieldValues = append(m.IssueFieldValues, mfv) + } + if r := issue.Reactions; r != nil { m.Reactions = &MinimalReactions{ TotalCount: r.GetTotalCount(), @@ -387,13 +558,14 @@ func convertToMinimalIssue(issue *github.Issue) MinimalIssue { func fragmentToMinimalIssue(fragment IssueFragment) MinimalIssue { m := MinimalIssue{ - Number: int(fragment.Number), - Title: sanitize.Sanitize(string(fragment.Title)), - Body: sanitize.Sanitize(string(fragment.Body)), - State: string(fragment.State), - Comments: int(fragment.Comments.TotalCount), - CreatedAt: fragment.CreatedAt.Format(time.RFC3339), - UpdatedAt: fragment.UpdatedAt.Format(time.RFC3339), + Number: int(fragment.Number), + Title: sanitize.Sanitize(string(fragment.Title)), + Body: sanitize.Sanitize(string(fragment.Body)), + State: string(fragment.State), + Comments: int(fragment.Comments.TotalCount), + CreatedAt: fragment.CreatedAt.Format(time.RFC3339), + UpdatedAt: fragment.UpdatedAt.Format(time.RFC3339), + AuthorAssociation: string(fragment.AuthorAssociation), User: &MinimalUser{ Login: string(fragment.Author.Login), }, @@ -403,9 +575,43 @@ func fragmentToMinimalIssue(fragment IssueFragment) MinimalIssue { m.Labels = append(m.Labels, string(label.Name)) } + for _, fv := range fragment.IssueFieldValues.Nodes { + if mfv, ok := fragmentToMinimalFieldValue(fv); ok { + m.FieldValues = append(m.FieldValues, mfv) + } + } + return m } +// fragmentToMinimalFieldValue flattens the union value fragment into a single +// {field, value} pair. Returns ok=false if the typename is unrecognised. +func fragmentToMinimalFieldValue(fv IssueFieldValueFragment) (MinimalFieldValue, bool) { + switch fv.TypeName { + case "IssueFieldDateValue": + return MinimalFieldValue{ + Field: fv.DateValue.Field.Name(), + Value: string(fv.DateValue.Value), + }, true + case "IssueFieldNumberValue": + return MinimalFieldValue{ + Field: fv.NumberValue.Field.Name(), + Value: strconv.FormatFloat(float64(fv.NumberValue.Value), 'f', -1, 64), + }, true + case "IssueFieldSingleSelectValue": + return MinimalFieldValue{ + Field: fv.SingleSelectValue.Field.Name(), + Value: string(fv.SingleSelectValue.Value), + }, true + case "IssueFieldTextValue": + return MinimalFieldValue{ + Field: fv.TextValue.Field.Name(), + Value: string(fv.TextValue.Value), + }, true + } + return MinimalFieldValue{}, false +} + func convertToMinimalIssuesResponse(fragment IssueQueryFragment) MinimalIssuesResponse { minimalIssues := make([]MinimalIssue, 0, len(fragment.Nodes)) for _, issue := range fragment.Nodes { @@ -424,6 +630,52 @@ func convertToMinimalIssuesResponse(fragment IssueQueryFragment) MinimalIssuesRe } } +// legacyFragmentToMinimalIssue converts the FeatureFlagIssueFields-disabled +// LegacyIssueFragment into a MinimalIssue. MinimalIssue.FieldValues is left +// nil so omitempty drops it from JSON output. Delete with the rest of the +// Legacy* block when the flag is removed. +func legacyFragmentToMinimalIssue(fragment LegacyIssueFragment) MinimalIssue { + m := MinimalIssue{ + Number: int(fragment.Number), + Title: sanitize.Sanitize(string(fragment.Title)), + Body: sanitize.Sanitize(string(fragment.Body)), + State: string(fragment.State), + Comments: int(fragment.Comments.TotalCount), + CreatedAt: fragment.CreatedAt.Format(time.RFC3339), + UpdatedAt: fragment.UpdatedAt.Format(time.RFC3339), + AuthorAssociation: string(fragment.AuthorAssociation), + User: &MinimalUser{ + Login: string(fragment.Author.Login), + }, + } + + for _, label := range fragment.Labels.Nodes { + m.Labels = append(m.Labels, string(label.Name)) + } + + return m +} + +// convertLegacyToMinimalIssuesResponse mirrors convertToMinimalIssuesResponse for +// the FeatureFlagIssueFields-disabled list_issues variant. +func convertLegacyToMinimalIssuesResponse(fragment LegacyIssueQueryFragment) MinimalIssuesResponse { + minimalIssues := make([]MinimalIssue, 0, len(fragment.Nodes)) + for _, issue := range fragment.Nodes { + minimalIssues = append(minimalIssues, legacyFragmentToMinimalIssue(issue)) + } + + return MinimalIssuesResponse{ + Issues: minimalIssues, + TotalCount: fragment.TotalCount, + PageInfo: MinimalPageInfo{ + HasNextPage: bool(fragment.PageInfo.HasNextPage), + HasPreviousPage: bool(fragment.PageInfo.HasPreviousPage), + StartCursor: string(fragment.PageInfo.StartCursor), + EndCursor: string(fragment.PageInfo.EndCursor), + }, + } +} + func convertToMinimalIssueComment(comment *github.IssueComment) MinimalIssueComment { m := MinimalIssueComment{ ID: comment.GetID(), @@ -495,16 +747,17 @@ func convertToMinimalFileContentResponse(resp *github.RepositoryContentResponse) func convertToMinimalPullRequest(pr *github.PullRequest) MinimalPullRequest { m := MinimalPullRequest{ - Number: pr.GetNumber(), - Title: pr.GetTitle(), - Body: pr.GetBody(), - State: pr.GetState(), - Draft: pr.GetDraft(), - Merged: pr.GetMerged(), - MergeableState: pr.GetMergeableState(), - HTMLURL: pr.GetHTMLURL(), - User: convertToMinimalUser(pr.GetUser()), - Additions: pr.GetAdditions(), + Number: pr.GetNumber(), + Title: pr.GetTitle(), + Body: pr.GetBody(), + State: pr.GetState(), + Draft: pr.GetDraft(), + Merged: pr.GetMerged(), + MergeableState: pr.GetMergeableState(), + HTMLURL: pr.GetHTMLURL(), + User: convertToMinimalUser(pr.GetUser()), + AuthorAssociation: pr.GetAuthorAssociation(), + Additions: pr.GetAdditions(), Deletions: pr.GetDeletions(), ChangedFiles: pr.GetChangedFiles(), Commits: pr.GetCommits(), @@ -604,6 +857,547 @@ func convertToMinimalProject(fullProject *github.ProjectV2) *MinimalProject { } } +func convertToMinimalProjectItem(item *github.ProjectV2Item) MinimalProjectItem { + if item == nil { + return MinimalProjectItem{} + } + + contentType := "" + if item.ContentType != nil { + contentType = string(*item.ContentType) + } + + creator := "" + if item.Creator != nil { + creator = item.Creator.GetLogin() + } + + return MinimalProjectItem{ + ID: item.GetID(), + NodeID: item.GetNodeID(), + ContentType: contentType, + Content: convertToMinimalProjectItemContent(item.GetContent()), + Fields: convertToMinimalProjectItemFields(item.GetFields()), + ArchivedAt: formatProjectTimestamp(item.ArchivedAt), + CreatedAt: formatProjectTimestamp(item.CreatedAt), + UpdatedAt: formatProjectTimestamp(item.UpdatedAt), + Creator: creator, + } +} + +func convertToMinimalProjectItemContent(content *github.ProjectV2ItemContent) *MinimalProjectItemContent { + if content == nil { + return nil + } + + if issue := content.GetIssue(); issue != nil { + return convertIssueToMinimalProjectItemContent(issue) + } + if pr := content.GetPullRequest(); pr != nil { + return convertPullRequestToMinimalProjectItemContent(pr) + } + if draftIssue := content.GetDraftIssue(); draftIssue != nil { + return convertDraftIssueToMinimalProjectItemContent(draftIssue) + } + + return nil +} + +func convertIssueToMinimalProjectItemContent(issue *github.Issue) *MinimalProjectItemContent { + m := &MinimalProjectItemContent{ + ID: issue.GetID(), + NodeID: issue.GetNodeID(), + Number: issue.GetNumber(), + Title: issue.GetTitle(), + State: issue.GetState(), + StateReason: issue.GetStateReason(), + HTMLURL: issue.GetHTMLURL(), + Repository: issueRepositoryFullName(issue), + Comments: issue.GetComments(), + Draft: issue.GetDraft(), + CreatedAt: formatProjectTimestamp(issue.CreatedAt), + UpdatedAt: formatProjectTimestamp(issue.UpdatedAt), + ClosedAt: formatProjectTimestamp(issue.ClosedAt), + } + + if user := issue.GetUser(); user != nil { + m.Author = user.GetLogin() + } + for _, assignee := range issue.Assignees { + if assignee != nil { + m.Assignees = append(m.Assignees, assignee.GetLogin()) + } + } + for _, label := range issue.Labels { + if label != nil { + m.Labels = append(m.Labels, label.GetName()) + } + } + if milestone := issue.GetMilestone(); milestone != nil { + m.Milestone = milestone.GetTitle() + } + + return m +} + +func convertPullRequestToMinimalProjectItemContent(pr *github.PullRequest) *MinimalProjectItemContent { + m := &MinimalProjectItemContent{ + ID: pr.GetID(), + NodeID: pr.GetNodeID(), + Number: pr.GetNumber(), + Title: pr.GetTitle(), + State: pr.GetState(), + HTMLURL: pr.GetHTMLURL(), + Repository: pullRequestRepositoryFullName(pr), + Comments: pr.GetComments(), + Draft: pr.GetDraft(), + Merged: pr.GetMerged(), + CreatedAt: formatProjectTimestamp(pr.CreatedAt), + UpdatedAt: formatProjectTimestamp(pr.UpdatedAt), + ClosedAt: formatProjectTimestamp(pr.ClosedAt), + MergedAt: formatProjectTimestamp(pr.MergedAt), + } + + if user := pr.GetUser(); user != nil { + m.Author = user.GetLogin() + } + for _, assignee := range pr.Assignees { + if assignee != nil { + m.Assignees = append(m.Assignees, assignee.GetLogin()) + } + } + for _, label := range pr.Labels { + if label != nil { + m.Labels = append(m.Labels, label.GetName()) + } + } + if milestone := pr.GetMilestone(); milestone != nil { + m.Milestone = milestone.GetTitle() + } + + return m +} + +func convertDraftIssueToMinimalProjectItemContent(draftIssue *github.ProjectV2DraftIssue) *MinimalProjectItemContent { + m := &MinimalProjectItemContent{ + ID: draftIssue.GetID(), + NodeID: draftIssue.GetNodeID(), + Title: draftIssue.GetTitle(), + CreatedAt: formatProjectTimestamp(draftIssue.CreatedAt), + UpdatedAt: formatProjectTimestamp(draftIssue.UpdatedAt), + } + + if user := draftIssue.GetUser(); user != nil { + m.Author = user.GetLogin() + } + + return m +} + +func convertToMinimalProjectItemFields(fields []*github.ProjectV2ItemFieldValue) []MinimalProjectItemFieldValue { + minimalFields := make([]MinimalProjectItemFieldValue, 0, len(fields)) + for _, field := range fields { + if field == nil { + continue + } + minimalFields = append(minimalFields, MinimalProjectItemFieldValue{ + ID: field.GetID(), + Name: field.GetName(), + DataType: field.GetDataType(), + Value: minimalProjectFieldValue(field.GetValue()), + }) + } + return minimalFields +} + +func minimalProjectFieldValue(value any) any { + switch v := value.(type) { + case nil: + return nil + case string, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return v + case []string: + return v + case map[string]any: + return minimalProjectMapValue(v) + case []any: + return minimalProjectArrayValue(v) + case *github.User: + return v.GetLogin() + case *github.Label: + return v.GetName() + case *github.Repository: + return v.GetFullName() + case *github.Milestone: + return v.GetTitle() + case *github.PullRequest: + return minimalProjectPullRequestRefFromPullRequest(v) + case *github.ProjectV2FieldOption: + return minimalProjectOptionValue{ + ID: v.GetID(), + Name: projectTextContentString(v.GetName()), + Color: v.GetColor(), + } + case *github.ProjectV2FieldIteration: + return minimalProjectIterationValue{ + ID: v.GetID(), + Title: projectTextContentString(v.GetTitle()), + StartDate: v.GetStartDate(), + Duration: v.GetDuration(), + } + case []*github.User: + logins := make([]string, 0, len(v)) + for _, user := range v { + if user != nil { + logins = append(logins, user.GetLogin()) + } + } + return logins + case []*github.Label: + names := make([]string, 0, len(v)) + for _, label := range v { + if label != nil { + names = append(names, label.GetName()) + } + } + return names + case []*github.PullRequest: + refs := make([]minimalProjectPullRequestRef, 0, len(v)) + for _, pr := range v { + if pr != nil { + refs = append(refs, minimalProjectPullRequestRefFromPullRequest(pr)) + } + } + return refs + default: + return nil + } +} + +func minimalProjectMapValue(value map[string]any) any { + if text := minimalProjectTextValue(value); text != "" { + return text + } + if repo := fullNameFromMap(value); repo != "" { + return repo + } + if login := stringFromMap(value, "login"); login != "" { + return login + } + if isPullRequestMap(value) { + return minimalProjectPullRequestRefFromMap(value) + } + if option, ok := minimalProjectOptionFromMap(value); ok { + return option + } + if iteration, ok := minimalProjectIterationFromMap(value); ok { + return iteration + } + if title := stringFromMap(value, "title"); title != "" { + return title + } + if name := stringFromMap(value, "name"); name != "" { + return name + } + + compact := make(map[string]any) + for key, nestedValue := range value { + minimalValue := minimalProjectFieldValue(nestedValue) + if shouldKeepMinimalProjectValue(minimalValue) { + compact[key] = minimalValue + } + } + if len(compact) == 0 { + return nil + } + return compact +} + +func minimalProjectArrayValue(values []any) any { + if refs, ok := minimalProjectPullRequestRefsFromArray(values); ok { + return refs + } + if strings, ok := minimalProjectStringsFromArray(values, "login"); ok { + return strings + } + if strings, ok := minimalProjectStringsFromArray(values, "name"); ok { + return strings + } + + compact := make([]any, 0, len(values)) + for _, value := range values { + minimalValue := minimalProjectFieldValue(value) + if shouldKeepMinimalProjectValue(minimalValue) { + compact = append(compact, minimalValue) + } + } + if len(compact) == 0 { + return nil + } + return compact +} + +func minimalProjectTextValue(value map[string]any) string { + if raw := stringFromMap(value, "raw"); raw != "" { + return raw + } + if html := stringFromMap(value, "html"); html != "" { + return html + } + return stringFromMap(value, "text") +} + +func minimalProjectOptionFromMap(value map[string]any) (minimalProjectOptionValue, bool) { + name := textContentStringFromMap(value, "name") + color := stringFromMap(value, "color") + if name == "" && color == "" { + return minimalProjectOptionValue{}, false + } + return minimalProjectOptionValue{ + ID: stringFromMap(value, "id"), + Name: name, + Color: color, + }, true +} + +func minimalProjectIterationFromMap(value map[string]any) (minimalProjectIterationValue, bool) { + startDate := stringFromMap(value, "start_date") + duration := intFromAny(value["duration"]) + if startDate == "" && duration == 0 { + return minimalProjectIterationValue{}, false + } + return minimalProjectIterationValue{ + ID: stringFromMap(value, "id"), + Title: textContentStringFromMap(value, "title"), + StartDate: startDate, + Duration: duration, + }, true +} + +// textContentStringFromMap returns a string for a field that may be either a +// plain string or a nested ProjectV2TextContent object (with raw/html/text +// fields), as returned for project option names and iteration titles. +func textContentStringFromMap(value map[string]any, key string) string { + if s := stringFromMap(value, key); s != "" { + return s + } + if nested, ok := value[key].(map[string]any); ok { + return minimalProjectTextValue(nested) + } + return "" +} + +func minimalProjectPullRequestRefsFromArray(values []any) ([]minimalProjectPullRequestRef, bool) { + refs := make([]minimalProjectPullRequestRef, 0, len(values)) + for _, value := range values { + switch pr := value.(type) { + case map[string]any: + if !isPullRequestMap(pr) { + return nil, false + } + refs = append(refs, minimalProjectPullRequestRefFromMap(pr)) + case *github.PullRequest: + if pr == nil { + continue + } + refs = append(refs, minimalProjectPullRequestRefFromPullRequest(pr)) + default: + return nil, false + } + } + return refs, len(refs) > 0 +} + +func minimalProjectStringsFromArray(values []any, key string) ([]string, bool) { + strings := make([]string, 0, len(values)) + for _, value := range values { + switch v := value.(type) { + case map[string]any: + stringValue := stringFromMap(v, key) + if stringValue == "" { + return nil, false + } + strings = append(strings, stringValue) + case *github.User: + if key != "login" || v == nil { + return nil, false + } + strings = append(strings, v.GetLogin()) + case *github.Label: + if key != "name" || v == nil { + return nil, false + } + strings = append(strings, v.GetName()) + default: + return nil, false + } + } + return strings, len(strings) > 0 +} + +func minimalProjectPullRequestRefFromPullRequest(pr *github.PullRequest) minimalProjectPullRequestRef { + if pr == nil { + return minimalProjectPullRequestRef{} + } + return minimalProjectPullRequestRef{ + Number: pr.GetNumber(), + Title: pr.GetTitle(), + State: pr.GetState(), + HTMLURL: pr.GetHTMLURL(), + Repository: pullRequestRepositoryFullName(pr), + } +} + +func minimalProjectPullRequestRefFromMap(value map[string]any) minimalProjectPullRequestRef { + htmlURL := stringFromMap(value, "html_url") + repository := fullNameFromMapValue(value["repository"]) + if repository == "" { + repository = branchRepositoryFullNameFromMap(value, "base") + } + if repository == "" { + repository = branchRepositoryFullNameFromMap(value, "head") + } + if repository == "" { + repository = repositoryFromHTMLURL(htmlURL) + } + + return minimalProjectPullRequestRef{ + Number: intFromAny(value["number"]), + Title: stringFromMap(value, "title"), + State: stringFromMap(value, "state"), + HTMLURL: htmlURL, + Repository: repository, + } +} + +func isPullRequestMap(value map[string]any) bool { + return intFromAny(value["number"]) != 0 && (stringFromMap(value, "html_url") != "" || stringFromMap(value, "state") != "") +} + +func branchRepositoryFullNameFromMap(value map[string]any, branchKey string) string { + branch, ok := value[branchKey].(map[string]any) + if !ok { + return "" + } + return fullNameFromMapValue(branch["repo"]) +} + +func shouldKeepMinimalProjectValue(value any) bool { + switch v := value.(type) { + case nil: + return false + case string: + return v != "" + case []any: + return len(v) > 0 + case []string: + return len(v) > 0 + case []minimalProjectPullRequestRef: + return len(v) > 0 + case map[string]any: + return len(v) > 0 + default: + return true + } +} + +func issueRepositoryFullName(issue *github.Issue) string { + if repo := issue.GetRepository(); repo != nil { + return repo.GetFullName() + } + return repositoryFromHTMLURL(issue.GetHTMLURL()) +} + +func pullRequestRepositoryFullName(pr *github.PullRequest) string { + if base := pr.GetBase(); base != nil { + if repo := base.GetRepo(); repo != nil && repo.GetFullName() != "" { + return repo.GetFullName() + } + } + if head := pr.GetHead(); head != nil { + if repo := head.GetRepo(); repo != nil && repo.GetFullName() != "" { + return repo.GetFullName() + } + } + return repositoryFromHTMLURL(pr.GetHTMLURL()) +} + +func fullNameFromMapValue(value any) string { + repo, ok := value.(map[string]any) + if !ok { + return "" + } + return fullNameFromMap(repo) +} + +func fullNameFromMap(value map[string]any) string { + return stringFromMap(value, "full_name") +} + +func repositoryFromHTMLURL(rawURL string) string { + if rawURL == "" { + return "" + } + parsedURL, err := url.Parse(rawURL) + if err != nil { + return "" + } + parts := strings.Split(strings.Trim(parsedURL.Path, "/"), "/") + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "" + } + return parts[0] + "/" + parts[1] +} + +func projectTextContentString(content *github.ProjectV2TextContent) string { + if content == nil { + return "" + } + if raw := content.GetRaw(); raw != "" { + return raw + } + return content.GetHTML() +} + +func formatProjectTimestamp(timestamp *github.Timestamp) string { + if timestamp == nil || timestamp.IsZero() { + return "" + } + return timestamp.Format(time.RFC3339) +} + +func stringFromMap(value map[string]any, key string) string { + return stringFromAny(value[key]) +} + +func stringFromAny(value any) string { + switch v := value.(type) { + case string: + return v + case fmt.Stringer: + return v.String() + default: + return "" + } +} + +func intFromAny(value any) int { + switch v := value.(type) { + case int: + return v + case float64: + return int(v) + case string: + i, err := strconv.Atoi(v) + if err != nil { + return 0 + } + return i + default: + return 0 + } +} + func convertToMinimalUser(user *github.User) *MinimalUser { if user == nil { return nil @@ -617,85 +1411,156 @@ func convertToMinimalUser(user *github.User) *MinimalUser { } } -// convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit -func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit { +// newMinimalCommitFromCore builds a MinimalCommit from the fields that are +// shared between *github.RepositoryCommit and *github.CommitResult. Caller +// is responsible for setting any type-specific extras (stats/files for +// RepositoryCommit, repository for CommitResult). +func newMinimalCommitFromCore(sha, htmlURL string, commit *github.Commit, author, committer *github.User) MinimalCommit { minimalCommit := MinimalCommit{ - SHA: commit.GetSHA(), - HTMLURL: commit.GetHTMLURL(), + SHA: sha, + HTMLURL: htmlURL, } - if commit.Commit != nil { + if commit != nil { minimalCommit.Commit = &MinimalCommitInfo{ - Message: commit.Commit.GetMessage(), + Message: commit.GetMessage(), } - if commit.Commit.Author != nil { + if commit.Author != nil { minimalCommit.Commit.Author = &MinimalCommitAuthor{ - Name: commit.Commit.Author.GetName(), - Email: commit.Commit.Author.GetEmail(), + Name: commit.Author.GetName(), + Email: commit.Author.GetEmail(), } - if commit.Commit.Author.Date != nil { - minimalCommit.Commit.Author.Date = commit.Commit.Author.Date.Format(time.RFC3339) + if commit.Author.Date != nil { + minimalCommit.Commit.Author.Date = commit.Author.Date.Format(time.RFC3339) } } - if commit.Commit.Committer != nil { + if commit.Committer != nil { minimalCommit.Commit.Committer = &MinimalCommitAuthor{ - Name: commit.Commit.Committer.GetName(), - Email: commit.Commit.Committer.GetEmail(), + Name: commit.Committer.GetName(), + Email: commit.Committer.GetEmail(), } - if commit.Commit.Committer.Date != nil { - minimalCommit.Commit.Committer.Date = commit.Commit.Committer.Date.Format(time.RFC3339) + if commit.Committer.Date != nil { + minimalCommit.Commit.Committer.Date = commit.Committer.Date.Format(time.RFC3339) } } } - if commit.Author != nil { + if author != nil { minimalCommit.Author = &MinimalUser{ - Login: commit.Author.GetLogin(), - ID: commit.Author.GetID(), - ProfileURL: commit.Author.GetHTMLURL(), - AvatarURL: commit.Author.GetAvatarURL(), + Login: author.GetLogin(), + ID: author.GetID(), + ProfileURL: author.GetHTMLURL(), + AvatarURL: author.GetAvatarURL(), } } - if commit.Committer != nil { + if committer != nil { minimalCommit.Committer = &MinimalUser{ - Login: commit.Committer.GetLogin(), - ID: commit.Committer.GetID(), - ProfileURL: commit.Committer.GetHTMLURL(), - AvatarURL: commit.Committer.GetAvatarURL(), + Login: committer.GetLogin(), + ID: committer.GetID(), + ProfileURL: committer.GetHTMLURL(), + AvatarURL: committer.GetAvatarURL(), } } - // Only include stats and files if includeDiffs is true - if includeDiffs { - if commit.Stats != nil { - minimalCommit.Stats = &MinimalCommitStats{ - Additions: commit.Stats.GetAdditions(), - Deletions: commit.Stats.GetDeletions(), - Total: commit.Stats.GetTotal(), - } + return minimalCommit +} + +// commitDetail controls how much per-file information convertToMinimalCommit +// includes in its output. +type commitDetail string + +const ( + // commitDetailNone omits Stats and Files entirely. + commitDetailNone commitDetail = "none" + // commitDetailStats includes Stats and Files with metadata only + // (filename, status, additions, deletions, changes) but no patch text. + commitDetailStats commitDetail = "stats" + // commitDetailFullPatch additionally includes the unified diff for each file. + commitDetailFullPatch commitDetail = "full_patch" +) + +// parseCommitDetail validates the user-supplied detail value and returns the +// default (stats) when the value is empty. +func parseCommitDetail(s string) (commitDetail, error) { + switch s { + case "": + return commitDetailStats, nil + case string(commitDetailNone), string(commitDetailStats), string(commitDetailFullPatch): + return commitDetail(s), nil + default: + return "", fmt.Errorf("invalid detail %q: must be one of \"none\", \"stats\", \"full_patch\"", s) + } +} + +func convertToMinimalCommit(commit *github.RepositoryCommit, detail commitDetail) MinimalCommit { + minimalCommit := newMinimalCommitFromCore( + commit.GetSHA(), + commit.GetHTMLURL(), + commit.Commit, + commit.Author, + commit.Committer, + ) + + if detail == commitDetailNone { + return minimalCommit + } + + if commit.Stats != nil { + minimalCommit.Stats = &MinimalCommitStats{ + Additions: commit.Stats.GetAdditions(), + Deletions: commit.Stats.GetDeletions(), + Total: commit.Stats.GetTotal(), } + } - if len(commit.Files) > 0 { - minimalCommit.Files = make([]MinimalCommitFile, 0, len(commit.Files)) - for _, file := range commit.Files { - minimalFile := MinimalCommitFile{ - Filename: file.GetFilename(), - Status: file.GetStatus(), - Additions: file.GetAdditions(), - Deletions: file.GetDeletions(), - Changes: file.GetChanges(), - } - minimalCommit.Files = append(minimalCommit.Files, minimalFile) + if len(commit.Files) > 0 { + minimalCommit.Files = make([]MinimalCommitFile, 0, len(commit.Files)) + for _, file := range commit.Files { + minimalFile := MinimalCommitFile{ + Filename: file.GetFilename(), + Status: file.GetStatus(), + Additions: file.GetAdditions(), + Deletions: file.GetDeletions(), + Changes: file.GetChanges(), } + if detail == commitDetailFullPatch { + minimalFile.Patch = file.GetPatch() + } + minimalCommit.Files = append(minimalCommit.Files, minimalFile) } } return minimalCommit } +// convertCommitResultToMinimalCommit converts a GitHub API commit search +// result, attaching the containing repository so the caller can tell which +// repo each result came from. +func convertCommitResultToMinimalCommit(commit *github.CommitResult) MinimalCommitSearchItem { + item := MinimalCommitSearchItem{ + MinimalCommit: newMinimalCommitFromCore( + commit.GetSHA(), + commit.GetHTMLURL(), + commit.Commit, + commit.Author, + commit.Committer, + ), + } + + if commit.Repository != nil { + item.Repository = &MinimalRepoRef{ + FullName: commit.Repository.GetFullName(), + HTMLURL: commit.Repository.GetHTMLURL(), + Private: commit.Repository.GetPrivate(), + } + } + + return item +} + // MinimalPageInfo contains pagination cursor information. type MinimalPageInfo struct { HasNextPage bool `json:"hasNextPage"` @@ -717,6 +1582,7 @@ type MinimalReviewComment struct { // MinimalReviewThread is the trimmed output type for PR review thread objects. type MinimalReviewThread struct { + ID string `json:"id"` IsResolved bool `json:"is_resolved"` IsOutdated bool `json:"is_outdated"` IsCollapsed bool `json:"is_collapsed"` @@ -853,6 +1719,7 @@ func convertToMinimalReviewThread(thread reviewThreadNode) MinimalReviewThread { } return MinimalReviewThread{ + ID: fmt.Sprintf("%v", thread.ID), IsResolved: bool(thread.IsResolved), IsOutdated: bool(thread.IsOutdated), IsCollapsed: bool(thread.IsCollapsed), diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index ddd3023932..61d8f40b2e 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "net/http" - "strconv" "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -14,7 +13,7 @@ import ( "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/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -209,13 +208,7 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT var resp *github.Response switch state { case "done": - // for some inexplicable reason, the API seems to have threadID as int64 and string depending on the endpoint - var threadIDInt int64 - threadIDInt, err = strconv.ParseInt(threadID, 10, 64) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid threadID format: %v", err)), nil, nil - } - resp, err = client.Activity.MarkThreadDone(ctx, threadIDInt) + resp, err = client.Activity.MarkThreadDone(ctx, threadID) case "read": resp, err = client.Activity.MarkThreadRead(ctx, threadID) default: diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go index 030367d067..bcfc28abc2 100644 --- a/pkg/github/notifications_test.go +++ b/pkg/github/notifications_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -108,7 +108,7 @@ func Test_ListNotifications(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -232,7 +232,7 @@ func Test_ManageNotificationSubscription(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -386,7 +386,7 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -456,7 +456,6 @@ func Test_DismissNotification(t *testing.T) { expectError bool expectRead bool expectDone bool - expectInvalid bool expectedErrMsg string }{ { @@ -495,16 +494,6 @@ func Test_DismissNotification(t *testing.T) { expectError: false, expectDone: true, }, - { - name: "invalid threadID format", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "threadID": "notanumber", - "state": "done", - }, - expectError: false, - expectInvalid: true, - }, { name: "missing required threadID", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), @@ -534,7 +523,7 @@ func Test_DismissNotification(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -552,8 +541,6 @@ func Test_DismissNotification(t *testing.T) { assert.Contains(t, text, "missing required parameter: threadID") case tc.requestArgs["state"] == nil: assert.Contains(t, text, "missing required parameter: state") - case tc.name == "invalid threadID format": - assert.Contains(t, text, "invalid threadID format") case tc.name == "invalid state value": assert.Contains(t, text, "Invalid state. Must be one of: read, done.") default: @@ -571,9 +558,6 @@ func Test_DismissNotification(t *testing.T) { if tc.expectDone { assert.Contains(t, textContent.Text, "Notification marked as done") } - if tc.expectInvalid { - assert.Contains(t, textContent.Text, "invalid threadID format") - } }) } } @@ -647,7 +631,7 @@ func Test_MarkAllNotificationsRead(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -725,7 +709,7 @@ func Test_GetNotificationDetails(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/params.go b/pkg/github/params.go index 1b45d61bd8..ecdc8c3549 100644 --- a/pkg/github/params.go +++ b/pkg/github/params.go @@ -6,7 +6,7 @@ import ( "math" "strconv" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" ) diff --git a/pkg/github/params_test.go b/pkg/github/params_test.go index 2254b737eb..b00efeb10c 100644 --- a/pkg/github/params_test.go +++ b/pkg/github/params_test.go @@ -5,7 +5,7 @@ import ( "math" "testing" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/assert" ) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index dcb9193eca..53ce510516 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strconv" "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -13,7 +14,7 @@ import ( "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/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -45,6 +46,8 @@ const ( projectsMethodListProjectStatusUpdates = "list_project_status_updates" projectsMethodGetProjectStatusUpdate = "get_project_status_update" projectsMethodCreateProjectStatusUpdate = "create_project_status_update" + projectsMethodCreateProject = "create_project" + projectsMethodCreateIterationField = "create_iteration_field" ) // GraphQL types for ProjectV2 status updates @@ -403,9 +406,9 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { ToolsetMetadataProjects, mcp.Tool{ Name: "projects_write", - Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Add, update, or delete project items, or create status updates in a GitHub Project."), + Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Create and manage GitHub Projects: create projects, add/update/delete items, create status updates, and add iteration fields."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Modify GitHub Project items"), + Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Manage GitHub Projects"), ReadOnlyHint: false, DestructiveHint: jsonschema.Ptr(true), }, @@ -420,11 +423,13 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { projectsMethodUpdateProjectItem, projectsMethodDeleteProjectItem, projectsMethodCreateProjectStatusUpdate, + projectsMethodCreateProject, + projectsMethodCreateIterationField, }, }, "owner_type": { Type: "string", - Description: "Owner type (user or org). If not provided, will be automatically detected.", + Description: "Owner type (user or org). Required for 'create_project' method. If not provided for other methods, will be automatically detected.", Enum: []any{"user", "org"}, }, "owner": { @@ -433,7 +438,11 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, "project_number": { Type: "number", - Description: "The project's number.", + Description: "The project's number. Required for all methods except 'create_project'.", + }, + "title": { + Type: "string", + Description: "The project title. Required for 'create_project' method.", }, "item_id": { Type: "number", @@ -475,14 +484,45 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, "start_date": { Type: "string", - Description: "The start date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", + Description: "Start date in YYYY-MM-DD format. Used for 'create_project_status_update' and 'create_iteration_field' methods.", }, "target_date": { Type: "string", Description: "The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", }, + "field_name": { + Type: "string", + Description: "The name of the iteration field (e.g. 'Sprint'). Required for 'create_iteration_field' method.", + }, + "iteration_duration": { + Type: "number", + Description: "Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method.", + }, + "iterations": { + Type: "array", + Description: "Custom iterations for 'create_iteration_field' method. Only set this when you need iterations with varying durations, breaks between them, or specific titles. Otherwise omit it: GitHub auto-creates three iterations of 'iteration_duration' days starting on 'start_date', which is the right choice for most cases.", + Items: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "title": { + Type: "string", + Description: "Iteration title (e.g. 'Sprint 1')", + }, + "start_date": { + Type: "string", + Description: "Start date in YYYY-MM-DD format", + }, + "duration": { + Type: "number", + Description: "Duration in days", + }, + }, + Required: []string{"title", "start_date", "duration"}, + }, + }, }, - Required: []string{"method", "owner", "project_number"}, + Required: []string{"method", "owner"}, }, }, []scopes.Scope{scopes.Project}, @@ -502,17 +542,22 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(err.Error()), nil, nil } - projectNumber, err := RequiredInt(args, "project_number") + gqlClient, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - client, err := deps.GetClient(ctx) + // create_project does not require project_number or a REST client + if method == projectsMethodCreateProject { + return createProject(ctx, gqlClient, owner, ownerType, args) + } + + projectNumber, err := RequiredInt(args, "project_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - gqlClient, err := deps.GetGQLClient(ctx) + client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } @@ -595,6 +640,8 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(err.Error()), nil, nil } return createProjectStatusUpdate(ctx, gqlClient, owner, ownerType, projectNumber, body, status, startDate, targetDate) + case projectsMethodCreateIterationField: + return createIterationField(ctx, gqlClient, owner, ownerType, projectNumber, args) default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } @@ -618,16 +665,11 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an var resp *github.Response var projects []*github.ProjectV2 - var queryPtr *string - - if queryStr != "" { - queryPtr = &queryStr - } minimalProjects := []MinimalProject{} opts := &github.ListProjectsOptions{ ListProjectsPaginationOptions: pagination, - Query: queryPtr, + Query: queryStr, } // If owner_type not provided, fetch from both user and org @@ -801,17 +843,12 @@ func listProjectItems(ctx context.Context, client *github.Client, args map[strin 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, + Query: queryStr, }, } @@ -830,8 +867,13 @@ func listProjectItems(ctx context.Context, client *github.Client, args map[strin } defer func() { _ = resp.Body.Close() }() + minimalItems := make([]MinimalProjectItem, 0, len(projectItems)) + for _, item := range projectItems { + minimalItems = append(minimalItems, convertToMinimalProjectItem(item)) + } + response := map[string]any{ - "items": projectItems, + "items": minimalItems, "pageInfo": buildPageInfo(resp), } @@ -949,7 +991,7 @@ func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project item", resp, body), nil, nil } - r, err := json.Marshal(projectItem) + r, err := json.Marshal(convertToMinimalProjectItem(projectItem)) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -988,7 +1030,7 @@ func updateProjectItem(ctx context.Context, client *github.Client, owner, ownerT } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectUpdateFailedError, resp, body), nil, nil } - r, err := json.Marshal(updatedItem) + r, err := json.Marshal(convertToMinimalProjectItem(updatedItem)) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -1084,7 +1126,8 @@ func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, owne var mutation struct { AddProjectV2ItemByID struct { Item struct { - ID githubv4.ID + ID githubv4.ID + FullDatabaseID string `graphql:"fullDatabaseId"` } } `graphql:"addProjectV2ItemById(input: $input)"` } @@ -1110,6 +1153,12 @@ func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, owne "id": mutation.AddProjectV2ItemByID.Item.ID, "message": fmt.Sprintf("Successfully added %s %s/%s#%d to project %s/%d", itemType, itemOwner, itemRepo, itemNumber, owner, projectNumber), } + if fullDatabaseID := mutation.AddProjectV2ItemByID.Item.FullDatabaseID; fullDatabaseID != "" { + result["full_database_id"] = fullDatabaseID + if itemID, err := strconv.ParseInt(fullDatabaseID, 10, 64); err == nil { + result["item_id"] = itemID + } + } r, err := json.Marshal(result) if err != nil { @@ -1387,16 +1436,9 @@ func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsP } opts := github.ListProjectsPaginationOptions{ - PerPage: &perPage, - } - - // Only set After/Before if they have non-empty values - if after != "" { - opts.After = &after - } - - if before != "" { - opts.Before = &before + PerPage: perPage, + After: after, + Before: before, } return opts, nil @@ -1450,6 +1492,265 @@ func resolvePullRequestNodeID(ctx context.Context, gqlClient *githubv4.Client, o return query.Repository.PullRequest.ID, nil } +// createProject handles the create_project method for ProjectsWrite. +func createProject(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, args map[string]any) (*mcp.CallToolResult, any, error) { + if ownerType == "" { + return utils.NewToolResultError("owner_type is required for create_project"), nil, nil + } + if ownerType != "user" && ownerType != "org" { + return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil + } + + title, err := RequiredParam[string](args, "title") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerID, err := getOwnerNodeID(ctx, gqlClient, owner, ownerType) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get owner ID: %v", err)), nil, nil + } + + var mutation struct { + CreateProjectV2 struct { + ProjectV2 struct { + ID string + Number int + Title string + URL string + } + } `graphql:"createProjectV2(input: $input)"` + } + + input := githubv4.CreateProjectV2Input{ + OwnerID: githubv4.ID(ownerID), + Title: githubv4.String(title), + } + + err = gqlClient.Mutate(ctx, &mutation, input, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create project: %v", err)), nil, nil + } + + result := struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + }{ + ID: mutation.CreateProjectV2.ProjectV2.ID, + Number: mutation.CreateProjectV2.ProjectV2.Number, + Title: mutation.CreateProjectV2.ProjectV2.Title, + URL: mutation.CreateProjectV2.ProjectV2.URL, + } + + return MarshalledTextResult(result), nil, nil +} + +// createIterationField handles the create_iteration_field method for ProjectsWrite. +// +// GitHub's GraphQL API requires two mutations to fully configure an iteration field: +// 1. createProjectV2Field creates the field with DataType=ITERATION (no schedule yet). +// 2. updateProjectV2Field sets the start date, duration, and optional named iterations. +// +// If step 2 fails, the field already exists with default settings and can be reconfigured +// by calling this method again (the create will fail with a duplicate-name error, which +// surfaces clearly) or by deleting the field via the GitHub UI. +func createIterationField(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, args map[string]any) (*mcp.CallToolResult, any, error) { + fieldName, err := RequiredParam[string](args, "field_name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + duration, err := RequiredInt(args, "iteration_duration") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + startDateStr, err := RequiredParam[string](args, "start_date") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil + } + + // Step 1: Create the iteration field. + var createMutation struct { + CreateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"createProjectV2Field(input: $input)"` + } + + createInput := githubv4.CreateProjectV2FieldInput{ + ProjectID: githubv4.ID(projectID), + DataType: githubv4.ProjectV2CustomFieldType("ITERATION"), + Name: githubv4.String(fieldName), + } + + err = gqlClient.Mutate(ctx, &createMutation, createInput, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create iteration field: %v", err)), nil, nil + } + + fieldID := createMutation.CreateProjectV2Field.ProjectV2Field.ProjectV2IterationField.ID + + // Step 2: Configure the iteration field with start date and duration. + var updateMutation struct { + UpdateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + Configuration struct { + Iterations []struct { + ID string + Title string + StartDate string + Duration int + } + } + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"updateProjectV2Field(input: $input)"` + } + + parsedStartDate, err := time.Parse("2006-01-02", startDateStr) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to parse start_date %s: %v", startDateStr, err)), nil, nil + } + + // GitHub's ProjectV2IterationFieldConfigurationInput requires `iterations` as a + // non-null array, so we always send at least an empty slice. When omitted, GitHub + // generates a default set of iterations from start_date and duration. + iterationsInput := []ProjectV2IterationFieldIterationInput{} + + if rawIterations, ok := args["iterations"].([]any); ok && len(rawIterations) > 0 { + for i, item := range rawIterations { + iterMap, ok := item.(map[string]any) + if !ok { + return utils.NewToolResultError(fmt.Sprintf("iterations[%d] must be an object", i)), nil, nil + } + iterTitle, ok := iterMap["title"].(string) + if !ok || iterTitle == "" { + return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: title is required and must be a non-empty string", i)), nil, nil + } + iterStartDate, ok := iterMap["start_date"].(string) + if !ok || iterStartDate == "" { + return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: start_date is required and must be a non-empty string", i)), nil, nil + } + iterDuration, ok := iterMap["duration"].(float64) + if !ok || iterDuration <= 0 { + return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: duration is required and must be a positive number", i)), nil, nil + } + + parsedIterStartDate, err := time.Parse("2006-01-02", iterStartDate) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: failed to parse start_date %q: %v", i, iterStartDate, err)), nil, nil + } + + iterationsInput = append(iterationsInput, ProjectV2IterationFieldIterationInput{ + Title: githubv4.String(iterTitle), + StartDate: githubv4.Date{Time: parsedIterStartDate}, + Duration: githubv4.Int(int32(iterDuration)), //nolint:gosec // Iteration durations are small day counts + }) + } + } + + configInput := ProjectV2IterationFieldConfigurationInput{ + Duration: githubv4.Int(int32(duration)), //nolint:gosec // Iteration durations are small day counts + StartDate: githubv4.Date{Time: parsedStartDate}, + Iterations: iterationsInput, + } + + updateInput := UpdateProjectV2FieldInput{ + FieldID: githubv4.ID(fieldID), + IterationConfiguration: &configInput, + } + + err = gqlClient.Mutate(ctx, &updateMutation, updateInput, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to update iteration configuration: %v", err)), nil, nil + } + + field := updateMutation.UpdateProjectV2Field.ProjectV2Field.ProjectV2IterationField + iterResults := make([]map[string]any, 0, len(field.Configuration.Iterations)) + for _, iter := range field.Configuration.Iterations { + iterResults = append(iterResults, map[string]any{ + "id": iter.ID, + "title": iter.Title, + "start_date": iter.StartDate, + "duration": iter.Duration, + }) + } + + result := map[string]any{ + "id": field.ID, + "name": field.Name, + "configuration": map[string]any{ + "iterations": iterResults, + }, + } + + return MarshalledTextResult(result), nil, nil +} + +// getOwnerNodeID resolves a GitHub user or organization login to its GraphQL node ID. +func getOwnerNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string) (string, error) { + if ownerType == "org" { + var query struct { + Organization struct { + ID string + } `graphql:"organization(login: $login)"` + } + variables := map[string]any{ + "login": githubv4.String(owner), + } + err := gqlClient.Query(ctx, &query, variables) + return query.Organization.ID, err + } + + var query struct { + User struct { + ID string + } `graphql:"user(login: $login)"` + } + variables := map[string]any{ + "login": githubv4.String(owner), + } + err := gqlClient.Query(ctx, &query, variables) + return query.User.ID, err +} + +// UpdateProjectV2FieldInput is the GraphQL input for the updateProjectV2Field mutation. +// These types are defined locally because the pinned shurcooL/githubv4 release +// (v0.0.0-20240727222349) does not yet expose them. Upstream master now generates +// equivalent types, so this block can be removed when the dependency is next bumped. +type UpdateProjectV2FieldInput struct { + FieldID githubv4.ID `json:"fieldId"` + IterationConfiguration *ProjectV2IterationFieldConfigurationInput `json:"iterationConfiguration,omitempty"` +} + +// ProjectV2IterationFieldConfigurationInput is the GraphQL input for configuring an iteration field. +// GitHub's schema marks iterations as a required non-null list, so the field is not omitempty. +type ProjectV2IterationFieldConfigurationInput struct { + Duration githubv4.Int `json:"duration"` + StartDate githubv4.Date `json:"startDate"` + Iterations []ProjectV2IterationFieldIterationInput `json:"iterations"` +} + +// ProjectV2IterationFieldIterationInput is the GraphQL input for a single iteration definition. +type ProjectV2IterationFieldIterationInput struct { + StartDate githubv4.Date `json:"startDate"` + Duration githubv4.Int `json:"duration"` + Title githubv4.String `json:"title"` +} + // 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) { diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 9b0e07292f..ad5ce6db86 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -9,7 +9,6 @@ import ( "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/v82/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -100,7 +99,7 @@ func Test_ProjectsList_ListProjects(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -140,7 +139,7 @@ func Test_ProjectsList_ListProjectFields(t *testing.T) { GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, fields), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -167,7 +166,7 @@ func Test_ProjectsList_ListProjectFields(t *testing.T) { t.Run("missing project_number", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -186,17 +185,159 @@ func Test_ProjectsList_ListProjectFields(t *testing.T) { }) } +func verbosePullRequestProjectItemFixture() map[string]any { + return map[string]any{ + "id": 1001, + "node_id": "PVTI_1", + "content_type": "PullRequest", + "item_url": "https://api.github.com/projectsV2/items/1001", + "project_url": "https://api.github.com/orgs/octo-org/projectsV2/1", + "creator": map[string]any{ + "login": "creator", + "id": 999, + "followers_url": "https://api.github.com/users/creator/followers", + }, + "content": map[string]any{ + "id": 2002, + "node_id": "PR_1", + "number": 42, + "title": "Reduce project item output", + "body": "Long pull request body that should not be returned from project item tools.", + "state": "closed", + "html_url": "https://github.com/cli/cli/pull/42", + "url": "https://api.github.com/repos/cli/cli/pulls/42", + "diff_url": "https://github.com/cli/cli/pull/42.diff", + "patch_url": "https://github.com/cli/cli/pull/42.patch", + "draft": false, + "merged": true, + "created_at": "2026-05-07T18:41:21Z", + "updated_at": "2026-05-07T21:21:57Z", + "closed_at": "2026-05-07T21:21:55Z", + "merged_at": "2026-05-07T21:21:55Z", + "user": map[string]any{ + "login": "octocat", + "id": 123, + "followers_url": "https://api.github.com/users/octocat/followers", + }, + "assignees": []map[string]any{ + { + "login": "hubot", + "events_url": "https://api.github.com/users/hubot/events{/privacy}", + }, + }, + "labels": []map[string]any{ + { + "name": "bug", + "url": "https://api.github.com/repos/cli/cli/labels/bug", + }, + }, + "milestone": map[string]any{ + "title": "v1.0", + "description": "Verbose milestone description", + }, + "head": map[string]any{ + "ref": "feature", + "repo": map[string]any{ + "full_name": "fork/cli", + "archive_url": "https://api.github.com/repos/fork/cli/{archive_format}{/ref}", + }, + }, + "base": map[string]any{ + "ref": "trunk", + "repo": map[string]any{ + "full_name": "cli/cli", + "archive_url": "https://api.github.com/repos/cli/cli/{archive_format}{/ref}", + }, + }, + "_links": map[string]any{ + "self": map[string]any{ + "href": "https://api.github.com/repos/cli/cli/pulls/42", + }, + }, + "statuses_url": "https://api.github.com/repos/cli/cli/statuses/abc123", + }, + "fields": []map[string]any{ + { + "id": 301, + "name": "Status", + "data_type": "single_select", + "value": map[string]any{ + "id": "opt1", + "name": "Done", + "color": "GREEN", + "description": "Verbose option description", + }, + }, + }, + "created_at": "2026-05-28T07:39:37Z", + "updated_at": "2026-05-28T07:40:15Z", + } +} + +func assertMinimalPullRequestProjectItem(t *testing.T, rawJSON string, item map[string]any) { + t.Helper() + + assert.Equal(t, float64(1001), item["id"]) + assert.Equal(t, "PVTI_1", item["node_id"]) + assert.Equal(t, "PullRequest", item["content_type"]) + assert.Equal(t, "creator", item["creator"]) + assert.Equal(t, "2026-05-28T07:39:37Z", item["created_at"]) + assert.Equal(t, "2026-05-28T07:40:15Z", item["updated_at"]) + + content, ok := item["content"].(map[string]any) + require.True(t, ok) + assert.Equal(t, float64(42), content["number"]) + assert.Equal(t, "Reduce project item output", content["title"]) + assert.Equal(t, "closed", content["state"]) + assert.Equal(t, "https://github.com/cli/cli/pull/42", content["html_url"]) + assert.Equal(t, "cli/cli", content["repository"]) + assert.Equal(t, "octocat", content["author"]) + assert.Equal(t, true, content["merged"]) + assert.Equal(t, "2026-05-07T18:41:21Z", content["created_at"]) + assert.Equal(t, "2026-05-07T21:21:57Z", content["updated_at"]) + assert.Equal(t, "2026-05-07T21:21:55Z", content["closed_at"]) + assert.Equal(t, "2026-05-07T21:21:55Z", content["merged_at"]) + assert.Equal(t, []any{"hubot"}, content["assignees"]) + assert.Equal(t, []any{"bug"}, content["labels"]) + assert.Equal(t, "v1.0", content["milestone"]) + + fields, ok := item["fields"].([]any) + require.True(t, ok) + require.Len(t, fields, 1) + field, ok := fields[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, float64(301), field["id"]) + assert.Equal(t, "Status", field["name"]) + assert.Equal(t, "single_select", field["data_type"]) + value, ok := field["value"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "opt1", value["id"]) + assert.Equal(t, "Done", value["name"]) + assert.Equal(t, "GREEN", value["color"]) + + assert.NotContains(t, rawJSON, `"body"`) + assert.NotContains(t, rawJSON, `"archive_url"`) + assert.NotContains(t, rawJSON, `"followers_url"`) + assert.NotContains(t, rawJSON, `"events_url"`) + assert.NotContains(t, rawJSON, `"_links"`) + assert.NotContains(t, rawJSON, `"head"`) + assert.NotContains(t, rawJSON, `"base"`) + assert.NotContains(t, rawJSON, `"url":`) + assert.NotContains(t, rawJSON, `"statuses_url"`) + assert.NotContains(t, rawJSON, `"diff_url"`) +} + 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"}}} + items := []map[string]any{verbosePullRequestProjectItemFixture()} t.Run("success organization", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -219,6 +360,9 @@ func Test_ProjectsList_ListProjectItems(t *testing.T) { itemsList, ok := response["items"].([]any) require.True(t, ok) assert.Equal(t, 1, len(itemsList)) + item, ok := itemsList[0].(map[string]any) + require.True(t, ok) + assertMinimalPullRequestProjectItem(t, textContent.Text, item) }) } @@ -249,7 +393,7 @@ func Test_ProjectsGet_GetProject(t *testing.T) { GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -274,7 +418,7 @@ func Test_ProjectsGet_GetProject(t *testing.T) { t.Run("unknown method", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -304,7 +448,7 @@ func Test_ProjectsGet_GetProjectField(t *testing.T) { GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusOK, field), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -330,7 +474,7 @@ func Test_ProjectsGet_GetProjectField(t *testing.T) { t.Run("missing field_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -353,14 +497,14 @@ func Test_ProjectsGet_GetProjectField(t *testing.T) { 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"}} + item := verbosePullRequestProjectItemFixture() t.Run("success organization", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, item), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -381,12 +525,12 @@ func Test_ProjectsGet_GetProjectItem(t *testing.T) { var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) - assert.NotNil(t, response["id"]) + assertMinimalPullRequestProjectItem(t, textContent.Text, response) }) t.Run("missing item_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -425,7 +569,7 @@ func Test_ProjectsWrite(t *testing.T) { 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"}) + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner"}) // Verify DestructiveHint is set assert.NotNil(t, toolDef.Tool.Annotations) @@ -486,7 +630,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { struct { AddProjectV2ItemByID struct { Item struct { - ID githubv4.ID + ID githubv4.ID + FullDatabaseID string `graphql:"fullDatabaseId"` } } `graphql:"addProjectV2ItemById(input: $input)"` }{}, @@ -498,7 +643,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { githubv4mock.DataResponse(map[string]any{ "addProjectV2ItemById": map[string]any{ "item": map[string]any{ - "id": "PVTI_item1", + "id": "PVTI_item1", + "fullDatabaseId": "1001", }, }, }), @@ -530,6 +676,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.NotNil(t, response["id"]) + assert.Equal(t, float64(1001), response["item_id"]) + assert.Equal(t, "1001", response["full_database_id"]) assert.Contains(t, response["message"], "Successfully added") }) @@ -583,7 +731,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { struct { AddProjectV2ItemByID struct { Item struct { - ID githubv4.ID + ID githubv4.ID + FullDatabaseID string `graphql:"fullDatabaseId"` } } `graphql:"addProjectV2ItemById(input: $input)"` }{}, @@ -595,7 +744,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { githubv4mock.DataResponse(map[string]any{ "addProjectV2ItemById": map[string]any{ "item": map[string]any{ - "id": "PVTI_item2", + "id": "PVTI_item2", + "fullDatabaseId": "1002", }, }, }), @@ -627,6 +777,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.NotNil(t, response["id"]) + assert.Equal(t, float64(1002), response["item_id"]) + assert.Equal(t, "1002", response["full_database_id"]) assert.Contains(t, response["message"], "Successfully added") }) @@ -704,14 +856,14 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) - updatedItem := map[string]any{"id": 1001, "archived_at": nil} + updatedItem := verbosePullRequestProjectItemFixture() t.Run("success organization", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -736,12 +888,12 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) - assert.NotNil(t, response["id"]) + assertMinimalPullRequestProjectItem(t, textContent.Text, response) }) t.Run("missing updated_field", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -772,7 +924,7 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { }), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -795,7 +947,7 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { t.Run("missing item_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -815,6 +967,108 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { }) } +func TestMinimalProjectFieldValue(t *testing.T) { + tests := []struct { + name string + value any + want any + }{ + { + name: "select option", + value: map[string]any{ + "id": "opt1", + "name": "Done", + "color": "GREEN", + "description": "verbose", + }, + want: minimalProjectOptionValue{ + ID: "opt1", + Name: "Done", + Color: "GREEN", + }, + }, + { + name: "iteration", + value: map[string]any{ + "id": "iter1", + "title": "Sprint 1", + "start_date": "2026-05-01", + "duration": float64(14), + }, + want: minimalProjectIterationValue{ + ID: "iter1", + Title: "Sprint 1", + StartDate: "2026-05-01", + Duration: 14, + }, + }, + { + name: "assignees", + value: []any{ + map[string]any{"login": "octocat", "followers_url": "https://api.github.com/users/octocat/followers"}, + map[string]any{"login": "hubot", "followers_url": "https://api.github.com/users/hubot/followers"}, + }, + want: []string{"octocat", "hubot"}, + }, + { + name: "labels", + value: []any{ + map[string]any{"name": "bug", "url": "https://api.github.com/repos/cli/cli/labels/bug"}, + map[string]any{"name": "help wanted", "url": "https://api.github.com/repos/cli/cli/labels/help%20wanted"}, + }, + want: []string{"bug", "help wanted"}, + }, + { + name: "repository", + value: map[string]any{ + "full_name": "cli/cli", + "archive_url": "https://api.github.com/repos/cli/cli/{archive_format}{/ref}", + }, + want: "cli/cli", + }, + { + name: "linked pull requests", + value: []any{ + map[string]any{ + "number": float64(42), + "title": "Reduce output", + "state": "open", + "html_url": "https://github.com/cli/cli/pull/42", + "base": map[string]any{ + "repo": map[string]any{ + "full_name": "cli/cli", + "archive_url": "https://api.github.com/repos/cli/cli/{archive_format}{/ref}", + }, + }, + }, + }, + want: []minimalProjectPullRequestRef{ + { + Number: 42, + Title: "Reduce output", + State: "open", + HTMLURL: "https://github.com/cli/cli/pull/42", + Repository: "cli/cli", + }, + }, + }, + { + name: "raw text content", + value: map[string]any{ + "raw": "plain text", + "html": "

plain text

", + }, + want: "plain text", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, minimalProjectFieldValue(tc.value)) + }) + } +} + func Test_ProjectsList_ListProjectStatusUpdates(t *testing.T) { toolDef := ProjectsList(translations.NullTranslationHelper) @@ -864,7 +1118,7 @@ func Test_ProjectsList_ListProjectStatusUpdates(t *testing.T) { gqlClient := githubv4.NewClient(gqlMockedClient) deps := BaseDeps{ - Client: gh.NewClient(restClient), + Client: mustNewGHClient(t, restClient), GQLClient: gqlClient, } handler := toolDef.Handler(deps) diff --git a/pkg/github/projects_v2_test.go b/pkg/github/projects_v2_test.go new file mode 100644 index 0000000000..69d4d6395f --- /dev/null +++ b/pkg/github/projects_v2_test.go @@ -0,0 +1,457 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ProjectsWrite_CreateProject(t *testing.T) { + t.Parallel() + + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + t.Run("success user project", func(t *testing.T) { + t.Parallel() + + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + User struct { + ID string + } `graphql:"user(login: $login)"` + }{}, + map[string]any{ + "login": githubv4.String("octocat"), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "id": "U_octocat", + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + CreateProjectV2 struct { + ProjectV2 struct { + ID string + Number int + Title string + URL string + } + } `graphql:"createProjectV2(input: $input)"` + }{}, + githubv4.CreateProjectV2Input{ + OwnerID: githubv4.ID("U_octocat"), + Title: githubv4.String("New Project"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createProjectV2": map[string]any{ + "projectV2": map[string]any{ + "id": "PVT_project123", + "number": 1, + "title": "New Project", + "url": "https://github.com/users/octocat/projects/1", + }, + }, + }), + ), + ) + + deps := BaseDeps{ + GQLClient: githubv4.NewClient(mockedClient), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_project", + "owner": "octocat", + "owner_type": "user", + "title": "New Project", + }) + 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.Equal(t, "PVT_project123", response["id"]) + assert.Equal(t, float64(1), response["number"]) + assert.Equal(t, "New Project", response["title"]) + assert.Equal(t, "https://github.com/users/octocat/projects/1", response["url"]) + }) + + t.Run("missing owner_type returns error", func(t *testing.T) { + t.Parallel() + + deps := BaseDeps{ + GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_project", + "owner": "octocat", + "title": "New Project", + }) + 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, "owner_type is required") + }) + + t.Run("invalid owner_type returns error", func(t *testing.T) { + t.Parallel() + + deps := BaseDeps{ + GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_project", + "owner": "octocat", + "owner_type": "invalid", + "title": "New Project", + }) + 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, "invalid owner_type") + assert.Contains(t, textContent.Text, "must be") + }) +} + +// resolveProjectNodeIDOrgMatcher returns a GraphQL query matcher for resolving +// an org project node ID via resolveProjectNodeID. +func resolveProjectNodeIDOrgMatcher(owner string, projectNumber int, nodeID string) githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // test constant + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{ + "id": nodeID, + }, + }, + }), + ) +} + +func createFieldMatcher() githubv4mock.Matcher { + return githubv4mock.NewMutationMatcher( + struct { + CreateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"createProjectV2Field(input: $input)"` + }{}, + githubv4.CreateProjectV2FieldInput{ + ProjectID: githubv4.ID("PVT_project1"), + DataType: githubv4.ProjectV2CustomFieldType("ITERATION"), + Name: githubv4.String("Sprint"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createProjectV2Field": map[string]any{ + "projectV2Field": map[string]any{ + "id": "PVTIF_field1", + "name": "Sprint", + }, + }, + }), + ) +} + +func updateFieldIterationResponse() githubv4mock.GQLResponse { + return githubv4mock.DataResponse(map[string]any{ + "updateProjectV2Field": map[string]any{ + "projectV2Field": map[string]any{ + "id": "PVTIF_field1", + "name": "Sprint", + "configuration": map[string]any{ + "iterations": []any{ + map[string]any{ + "id": "PVTI_iter1", + "title": "Sprint 1", + "startDate": "2025-01-20", + "duration": 7, + }, + }, + }, + }, + }, + }) +} + +func Test_ProjectsWrite_CreateIterationField(t *testing.T) { + t.Parallel() + + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + t.Run("success with iterations", func(t *testing.T) { + t.Parallel() + + mockGQLClient := githubv4mock.NewMockedHTTPClient( + resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"), + createFieldMatcher(), + githubv4mock.NewMutationMatcher( + struct { + UpdateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + Configuration struct { + Iterations []struct { + ID string + Title string + StartDate string + Duration int + } + } + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"updateProjectV2Field(input: $input)"` + }{}, + UpdateProjectV2FieldInput{ + FieldID: githubv4.ID("PVTIF_field1"), + IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{ + Duration: githubv4.Int(7), + StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)}, + Iterations: []ProjectV2IterationFieldIterationInput{ + { + Title: githubv4.String("Sprint 1"), + StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)}, + Duration: githubv4.Int(7), + }, + }, + }, + }, + nil, + updateFieldIterationResponse(), + ), + ) + + deps := BaseDeps{ + GQLClient: githubv4.NewClient(mockGQLClient), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_iteration_field", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "field_name": "Sprint", + "iteration_duration": float64(7), + "start_date": "2025-01-20", + "iterations": []any{ + map[string]any{ + "title": "Sprint 1", + "start_date": "2025-01-20", + "duration": float64(7), + }, + }, + }) + 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.Equal(t, "PVTIF_field1", response["id"]) + }) + + t.Run("success without iterations", func(t *testing.T) { + t.Parallel() + + mockGQLClient := githubv4mock.NewMockedHTTPClient( + resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"), + createFieldMatcher(), + githubv4mock.NewMutationMatcher( + struct { + UpdateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + Configuration struct { + Iterations []struct { + ID string + Title string + StartDate string + Duration int + } + } + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"updateProjectV2Field(input: $input)"` + }{}, + UpdateProjectV2FieldInput{ + FieldID: githubv4.ID("PVTIF_field1"), + IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{ + Duration: githubv4.Int(7), + StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)}, + Iterations: []ProjectV2IterationFieldIterationInput{}, + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateProjectV2Field": map[string]any{ + "projectV2Field": map[string]any{ + "id": "PVTIF_field1", + "name": "Sprint", + "configuration": map[string]any{ + "iterations": []any{}, + }, + }, + }, + }), + ), + ) + + deps := BaseDeps{ + GQLClient: githubv4.NewClient(mockGQLClient), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_iteration_field", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "field_name": "Sprint", + "iteration_duration": float64(7), + "start_date": "2025-01-20", + }) + 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.Equal(t, "PVTIF_field1", response["id"]) + }) + + t.Run("success with auto-detected owner_type", func(t *testing.T) { + t.Parallel() + + // detectOwnerType uses REST to probe user first, then org + mockRESTClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusNotFound, nil), + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, map[string]any{ + "id": 1, + "node_id": "PVT_project1", + "title": "Org Project", + }), + }) + + mockGQLClient := githubv4mock.NewMockedHTTPClient( + resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"), + createFieldMatcher(), + githubv4mock.NewMutationMatcher( + struct { + UpdateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + Configuration struct { + Iterations []struct { + ID string + Title string + StartDate string + Duration int + } + } + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"updateProjectV2Field(input: $input)"` + }{}, + UpdateProjectV2FieldInput{ + FieldID: githubv4.ID("PVTIF_field1"), + IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{ + Duration: githubv4.Int(14), + StartDate: githubv4.Date{Time: time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC)}, + Iterations: []ProjectV2IterationFieldIterationInput{}, + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateProjectV2Field": map[string]any{ + "projectV2Field": map[string]any{ + "id": "PVTIF_field1", + "name": "Sprint", + "configuration": map[string]any{ + "iterations": []any{}, + }, + }, + }, + }), + ), + ) + + deps := BaseDeps{ + Client: mustNewGHClient(t, mockRESTClient), + GQLClient: githubv4.NewClient(mockGQLClient), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_iteration_field", + "owner": "octo-org", + "project_number": float64(1), + "field_name": "Sprint", + "iteration_duration": float64(14), + "start_date": "2025-02-01", + }) + 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.Equal(t, "PVTIF_field1", response["id"]) + }) +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 731db49314..05028850d7 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -8,7 +8,7 @@ import ( "net/http" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -36,7 +36,7 @@ Possible options: 3. get_status - Get combined commit status of a head commit in a pull request. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. 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. - 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. + 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned. 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. 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. `, @@ -58,6 +58,13 @@ Possible options: Required: []string{"method", "owner", "repo", "pullNumber"}, } WithPagination(schema) + // get_review_comments uses GraphQL cursor-based pagination and accepts the + // `after` cursor. Other methods rely on the `page`/`perPage` parameters + // added by WithPagination and ignore `after`. + schema.Properties["after"] = &jsonschema.Schema{ + Type: "string", + Description: "Cursor for pagination, used only by the get_review_comments method. Pass the endCursor from the previous page's PageInfo to fetch the next page.", + } return NewTool( ToolsetMetadataPullRequests, @@ -124,7 +131,7 @@ Possible options: result, err := GetPullRequestReviewComments(ctx, gqlClient, deps, owner, repo, pullNumber, cursorPagination) return result, nil, err case "get_reviews": - result, err := GetPullRequestReviews(ctx, client, deps, owner, repo, pullNumber) + result, err := GetPullRequestReviews(ctx, client, deps, owner, repo, pullNumber, pagination) return result, nil, err case "get_comments": result, err := GetIssueComments(ctx, client, deps, owner, repo, pullNumber, pagination) @@ -478,14 +485,17 @@ func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Clien return MarshalledTextResult(convertToMinimalReviewThreadsResponse(query)), nil } -func GetPullRequestReviews(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { +func GetPullRequestReviews(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { return nil, fmt.Errorf("failed to get repo access cache: %w", err) } ff := deps.GetFlags(ctx) - reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, nil) + reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, &github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request reviews", @@ -534,6 +544,37 @@ func GetPullRequestReviews(ctx context.Context, client *github.Client, deps Tool // PullRequestWriteUIResourceURI is the URI for the create_pull_request tool's MCP App UI resource. const PullRequestWriteUIResourceURI = "ui://github-mcp-server/pr-write" +// pullRequestWriteFormParams are the parameters the create_pull_request MCP App +// form collects and re-sends on submit. Any other parameter present on a call +// cannot be represented by the form. +var pullRequestWriteFormParams = map[string]struct{}{ + "owner": {}, + "repo": {}, + "title": {}, + "body": {}, + "head": {}, + "base": {}, + "draft": {}, + "maintainer_can_modify": {}, + "_ui_submitted": {}, +} + +// pullRequestWriteHasNonFormParams reports whether the call carries any parameter +// the create_pull_request MCP App form cannot represent (anything outside +// pullRequestWriteFormParams). Such calls must bypass the UI form and execute +// directly so the supplied values aren't silently dropped. +func pullRequestWriteHasNonFormParams(args map[string]any) bool { + for key, value := range args { + if value == nil { + continue + } + if _, ok := pullRequestWriteFormParams[key]; !ok { + return true + } + } + return false +} + // CreatePullRequest creates a tool to create a new pull request. func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( @@ -601,12 +642,14 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(err.Error()), nil, nil } - // When insiders mode is enabled and the client supports MCP Apps UI, - // check if this is a UI form submission. The UI sends _ui_submitted=true - // to distinguish form submissions from LLM calls. + // When MCP Apps are enabled and the client supports UI, route the + // call to the interactive form unless it is itself a form submission + // (the UI sends _ui_submitted=true) or it carries parameters the form + // cannot represent. Those must be applied directly so their values + // aren't silently dropped. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") - if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(ctx, req) && !uiSubmitted { + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !pullRequestWriteHasNonFormParams(args) { return utils.NewToolResultText(fmt.Sprintf("Ready to create a pull request in %s/%s. IMPORTANT: The PR has NOT been created yet. Do NOT tell the user the PR was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil } @@ -742,7 +785,7 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo }, "reviewers": { Type: "array", - Description: "GitHub usernames to request reviews from", + Description: "GitHub usernames or ORG/team-slug team reviewers to request reviews from", Items: &jsonschema.Schema{ Type: "string", }, @@ -751,7 +794,7 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo", "pullNumber"}, } - return NewTool( + st := NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "update_pull_request", @@ -934,8 +977,10 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } + userReviewers, teamReviewers := splitPullRequestReviewers(reviewers) reviewersRequest := github.ReviewersRequest{ - Reviewers: reviewers, + Reviewers: userReviewers, + TeamReviewers: teamReviewers, } _, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, reviewersRequest) @@ -990,6 +1035,8 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultText(string(r)), nil, nil }) + st.FeatureFlagDisable = []string{FeatureFlagPullRequestsGranular} + return st } // AddReplyToPullRequestComment creates a tool to add a reply to an existing pull request comment. @@ -1555,7 +1602,7 @@ func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.Serv Required: []string{"method", "owner", "repo", "pullNumber"}, } - return NewTool( + st := NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "pull_request_review_write", @@ -1569,7 +1616,7 @@ Available methods: - unresolve_thread: Unresolve a previously resolved review thread. Requires only "threadId" parameter. The owner, repo, and pullNumber parameters are not used for this method. Unresolving an already-unresolved thread is a no-op. `), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_PULL_REQUEST_REVIEW_WRITE_USER_TITLE", "Write operations (create, submit, delete) on pull request reviews."), + Title: t("TOOL_PULL_REQUEST_REVIEW_WRITE_USER_TITLE", "Write operations (create, submit, delete) on pull request reviews"), ReadOnlyHint: false, }, InputSchema: schema, @@ -1607,6 +1654,8 @@ Available methods: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", params.Method)), nil, nil } }) + st.FeatureFlagDisable = []string{FeatureFlagPullRequestsGranular} + return st } func CreatePullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { @@ -1886,6 +1935,113 @@ func ResolveReviewThread(ctx context.Context, client *githubv4.Client, threadID return utils.NewToolResultText("review thread unresolved successfully"), nil } +// AddCommentToPendingReviewParams contains the parameters for adding a comment to a pending review. +type AddCommentToPendingReviewParams struct { + Owner string + Repo string + PullNumber int32 + Path string + Body string + SubjectType string + Line *int32 + Side *string + StartLine *int32 + StartSide *string +} + +// AddCommentToPendingReviewCall adds a review comment to the viewer's pending pull request review. +func AddCommentToPendingReviewCall(ctx context.Context, client *githubv4.Client, params AddCommentToPendingReviewParams) (*mcp.CallToolResult, error) { + // Get the current user + var getViewerQuery struct { + Viewer struct { + Login githubv4.String + } + } + + if err := client.Query(ctx, &getViewerQuery, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to get current user", + err, + ), nil + } + + var getLatestReviewForViewerQuery struct { + Repository struct { + PullRequest struct { + Reviews struct { + Nodes []struct { + ID githubv4.ID + State githubv4.PullRequestReviewState + URL githubv4.URI + } + } `graphql:"reviews(first: 1, author: $author)"` + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + vars := map[string]any{ + "author": githubv4.String(getViewerQuery.Viewer.Login), + "owner": githubv4.String(params.Owner), + "name": githubv4.String(params.Repo), + "prNum": githubv4.Int(params.PullNumber), + } + + if err := client.Query(ctx, &getLatestReviewForViewerQuery, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to get latest review for current user", + err, + ), nil + } + + // Validate there is one review and the state is pending + if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { + return utils.NewToolResultError("No pending review found for the viewer"), nil + } + + review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] + if review.State != githubv4.PullRequestReviewStatePending { + errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) + return utils.NewToolResultError(errText), nil + } + + // Create a new review thread comment on the review. + var addPullRequestReviewThreadMutation struct { + AddPullRequestReviewThread struct { + Thread struct { + ID githubv4.ID + } + } `graphql:"addPullRequestReviewThread(input: $input)"` + } + + if err := client.Mutate( + ctx, + &addPullRequestReviewThreadMutation, + githubv4.AddPullRequestReviewThreadInput{ + Path: githubv4.String(params.Path), + Body: githubv4.String(params.Body), + SubjectType: newGQLStringlikePtr[githubv4.PullRequestReviewThreadSubjectType](¶ms.SubjectType), + Line: newGQLIntPtr(params.Line), + Side: newGQLStringlikePtr[githubv4.DiffSide](params.Side), + StartLine: newGQLIntPtr(params.StartLine), + StartSide: newGQLStringlikePtr[githubv4.DiffSide](params.StartSide), + PullRequestReviewID: &review.ID, + }, + nil, + ); err != nil { + return utils.NewToolResultError(err.Error()), nil + } + + if addPullRequestReviewThreadMutation.AddPullRequestReviewThread.Thread.ID == nil { + return utils.NewToolResultError(`Failed to add comment to pending review. Possible reasons: + - The line number doesn't exist in the pull request diff + - The file path is incorrect + - The side (LEFT/RIGHT) is invalid for the specified line +`), nil + } + + return utils.NewToolResultText("pull request review comment successfully added to pending review"), nil +} + // AddCommentToPendingReview creates a tool to add a comment to a pull request review. func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ @@ -1947,7 +2103,7 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S Required: []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"}, } - return NewTool( + st := NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "add_comment_to_pending_review", @@ -1981,99 +2137,22 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S return utils.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil, nil } - // First we'll get the current user - var getViewerQuery struct { - Viewer struct { - Login githubv4.String - } - } - - if err := client.Query(ctx, &getViewerQuery, nil); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, - "failed to get current user", - err, - ), nil, nil - } - - var getLatestReviewForViewerQuery struct { - Repository struct { - PullRequest struct { - Reviews struct { - Nodes []struct { - ID githubv4.ID - State githubv4.PullRequestReviewState - URL githubv4.URI - } - } `graphql:"reviews(first: 1, author: $author)"` - } `graphql:"pullRequest(number: $prNum)"` - } `graphql:"repository(owner: $owner, name: $name)"` - } - - vars := map[string]any{ - "author": githubv4.String(getViewerQuery.Viewer.Login), - "owner": githubv4.String(params.Owner), - "name": githubv4.String(params.Repo), - "prNum": githubv4.Int(params.PullNumber), - } - - if err := client.Query(ctx, &getLatestReviewForViewerQuery, vars); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, - "failed to get latest review for current user", - err, - ), nil, nil - } - - // Validate there is one review and the state is pending - if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { - return utils.NewToolResultError("No pending review found for the viewer"), nil, nil - } - - review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] - if review.State != githubv4.PullRequestReviewStatePending { - errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) - return utils.NewToolResultError(errText), nil, nil - } - - // Then we can create a new review thread comment on the review. - var addPullRequestReviewThreadMutation struct { - AddPullRequestReviewThread struct { - Thread struct { - ID githubv4.ID // We don't need this, but a selector is required or GQL complains. - } - } `graphql:"addPullRequestReviewThread(input: $input)"` - } - - if err := client.Mutate( - ctx, - &addPullRequestReviewThreadMutation, - githubv4.AddPullRequestReviewThreadInput{ - Path: githubv4.String(params.Path), - Body: githubv4.String(params.Body), - SubjectType: newGQLStringlikePtr[githubv4.PullRequestReviewThreadSubjectType](¶ms.SubjectType), - Line: newGQLIntPtr(params.Line), - Side: newGQLStringlikePtr[githubv4.DiffSide](params.Side), - StartLine: newGQLIntPtr(params.StartLine), - StartSide: newGQLStringlikePtr[githubv4.DiffSide](params.StartSide), - PullRequestReviewID: &review.ID, - }, - nil, - ); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - if addPullRequestReviewThreadMutation.AddPullRequestReviewThread.Thread.ID == nil { - return utils.NewToolResultError(`Failed to add comment to pending review. Possible reasons: - - The line number doesn't exist in the pull request diff - - The file path is incorrect - - The side (LEFT/RIGHT) is invalid for the specified line -`), nil, nil - } - - // Return nothing interesting, just indicate success for the time being. - // In future, we may want to return the review ID, but for the moment, we're not leaking - // API implementation details to the LLM. - return utils.NewToolResultText("pull request review comment successfully added to pending review"), nil, nil + result, err := AddCommentToPendingReviewCall(ctx, client, AddCommentToPendingReviewParams{ + Owner: params.Owner, + Repo: params.Repo, + PullNumber: params.PullNumber, + Path: params.Path, + Body: params.Body, + SubjectType: params.SubjectType, + Line: params.Line, + Side: params.Side, + StartLine: params.StartLine, + StartSide: params.StartSide, + }) + return result, nil, err }) + st.FeatureFlagDisable = []string{FeatureFlagPullRequestsGranular} + return st } // newGQLString like takes something that approximates a string (of which there are many types in shurcooL/githubv4) diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go new file mode 100644 index 0000000000..6bc2b99f36 --- /dev/null +++ b/pkg/github/pullrequests_granular.go @@ -0,0 +1,759 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "strings" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + gogithub "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +// prUpdateTool is a helper to create single-field pull request update tools via REST. +func prUpdateTool( + t translations.TranslationHelperFunc, + name, description, title string, + extraProps map[string]*jsonschema.Schema, + extraRequired []string, + buildRequest func(args map[string]any) (*gogithub.PullRequest, error), +) inventory.ServerTool { + props := map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "pullNumber": { + Type: "number", + Description: "The pull request number", + Minimum: jsonschema.Ptr(1.0), + }, + } + maps.Copy(props, extraProps) + + required := append([]string{"owner", "repo", "pullNumber"}, extraRequired...) + + st := NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: name, + Description: t("TOOL_"+strings.ToUpper(name)+"_DESCRIPTION", description), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_"+strings.ToUpper(name)+"_USER_TITLE", title), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: props, + Required: required, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pullNumber, err := RequiredInt(args, "pullNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + prReq, err := buildRequest(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + pr, resp, err := client.PullRequests.Edit(ctx, owner, repo, pullNumber, prReq) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update pull request", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%d", pr.GetID()), + URL: pr.GetHTMLURL(), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + return st +} + +// GranularUpdatePullRequestTitle creates a tool to update a PR's title. +func GranularUpdatePullRequestTitle(t translations.TranslationHelperFunc) inventory.ServerTool { + return prUpdateTool(t, + "update_pull_request_title", + "Update the title of an existing pull request.", + "Update Pull Request Title", + map[string]*jsonschema.Schema{ + "title": {Type: "string", Description: "The new title for the pull request"}, + }, + []string{"title"}, + func(args map[string]any) (*gogithub.PullRequest, error) { + title, err := RequiredParam[string](args, "title") + if err != nil { + return nil, err + } + return &gogithub.PullRequest{Title: &title}, nil + }, + ) +} + +// GranularUpdatePullRequestBody creates a tool to update a PR's body. +func GranularUpdatePullRequestBody(t translations.TranslationHelperFunc) inventory.ServerTool { + return prUpdateTool(t, + "update_pull_request_body", + "Update the body description of an existing pull request.", + "Update Pull Request Body", + map[string]*jsonschema.Schema{ + "body": {Type: "string", Description: "The new body content for the pull request"}, + }, + []string{"body"}, + func(args map[string]any) (*gogithub.PullRequest, error) { + body, err := RequiredParam[string](args, "body") + if err != nil { + return nil, err + } + return &gogithub.PullRequest{Body: &body}, nil + }, + ) +} + +// GranularUpdatePullRequestState creates a tool to update a PR's state. +func GranularUpdatePullRequestState(t translations.TranslationHelperFunc) inventory.ServerTool { + return prUpdateTool(t, + "update_pull_request_state", + "Update the state of an existing pull request (open or closed).", + "Update Pull Request State", + map[string]*jsonschema.Schema{ + "state": { + Type: "string", + Description: "The new state for the pull request", + Enum: []any{"open", "closed"}, + }, + }, + []string{"state"}, + func(args map[string]any) (*gogithub.PullRequest, error) { + state, err := RequiredParam[string](args, "state") + if err != nil { + return nil, err + } + return &gogithub.PullRequest{State: &state}, nil + }, + ) +} + +// GranularUpdatePullRequestDraftState creates a tool to toggle draft state. +func GranularUpdatePullRequestDraftState(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "update_pull_request_draft_state", + Description: t("TOOL_UPDATE_PULL_REQUEST_DRAFT_STATE_DESCRIPTION", "Mark a pull request as draft or ready for review."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_PULL_REQUEST_DRAFT_STATE_USER_TITLE", "Update Pull Request Draft State"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string", Description: "Repository owner (username or organization)"}, + "repo": {Type: "string", Description: "Repository name"}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)}, + "draft": {Type: "boolean", Description: "Set to true to convert to draft, false to mark as ready for review"}, + }, + Required: []string{"owner", "repo", "pullNumber", "draft"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pullNumber, err := RequiredInt(args, "pullNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + // Use presence check + OptionalParam since RequiredParam rejects false (zero-value for bool) + if _, ok := args["draft"]; !ok { + return utils.NewToolResultError("missing required parameter: draft"), nil, nil + } + draft, err := OptionalParam[bool](args, "draft") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + + // Get PR node ID + var prQuery struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + } `graphql:"pullRequest(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + if err := gqlClient.Query(ctx, &prQuery, map[string]any{ + "owner": githubv4.String(owner), + "name": githubv4.String(repo), + "number": githubv4.Int(pullNumber), // #nosec G115 - PR numbers are always small positive integers + }); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get pull request", err), nil, nil + } + + if draft { + var mutation struct { + ConvertPullRequestToDraft struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } + } `graphql:"convertPullRequestToDraft(input: $input)"` + } + if err := gqlClient.Mutate(ctx, &mutation, githubv4.ConvertPullRequestToDraftInput{ + PullRequestID: prQuery.Repository.PullRequest.ID, + }, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to convert to draft", err), nil, nil + } + return utils.NewToolResultText("pull request converted to draft"), nil, nil + } + + var mutation struct { + MarkPullRequestReadyForReview struct { + PullRequest struct { + ID githubv4.ID + IsDraft githubv4.Boolean + } + } `graphql:"markPullRequestReadyForReview(input: $input)"` + } + if err := gqlClient.Mutate(ctx, &mutation, githubv4.MarkPullRequestReadyForReviewInput{ + PullRequestID: prQuery.Repository.PullRequest.ID, + }, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to mark ready for review", err), nil, nil + } + return utils.NewToolResultText("pull request marked as ready for review"), nil, nil + }, + ) + st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + return st +} + +// GranularRequestPullRequestReviewers creates a tool to request reviewers. +func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "request_pull_request_reviewers", + Description: t("TOOL_REQUEST_PULL_REQUEST_REVIEWERS_DESCRIPTION", "Request reviewers for a pull request."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_REQUEST_PULL_REQUEST_REVIEWERS_USER_TITLE", "Request Pull Request Reviewers"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string", Description: "Repository owner (username or organization)"}, + "repo": {Type: "string", Description: "Repository name"}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)}, + "reviewers": { + Type: "array", + Description: "GitHub usernames or ORG/team-slug team reviewers to request reviews from", + Items: &jsonschema.Schema{Type: "string"}, + }, + }, + Required: []string{"owner", "repo", "pullNumber", "reviewers"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pullNumber, err := RequiredInt(args, "pullNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + reviewers, err := OptionalStringArrayParam(args, "reviewers") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if len(reviewers) == 0 { + return utils.NewToolResultError("missing required parameter: reviewers"), nil, nil + } + userReviewers, teamReviewers := splitPullRequestReviewers(reviewers) + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + pr, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, gogithub.ReviewersRequest{ + Reviewers: userReviewers, + TeamReviewers: teamReviewers, + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to request reviewers", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%d", pr.GetID()), + URL: pr.GetHTMLURL(), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + return st +} + +func splitPullRequestReviewers(reviewers []string) ([]string, []string) { + userReviewers := make([]string, 0, len(reviewers)) + teamReviewers := make([]string, 0) + + for _, reviewer := range reviewers { + org, team, ok := strings.Cut(reviewer, "/") + if ok && org != "" && team != "" && !strings.Contains(team, "/") { + teamReviewers = append(teamReviewers, team) + continue + } + userReviewers = append(userReviewers, reviewer) + } + + return userReviewers, teamReviewers +} + +// GranularCreatePullRequestReview creates a tool to create a PR review. +func GranularCreatePullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "create_pull_request_review", + Description: t("TOOL_CREATE_PULL_REQUEST_REVIEW_DESCRIPTION", "Create a review on a pull request. If event is provided, the review is submitted immediately; otherwise a pending review is created."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_PULL_REQUEST_REVIEW_USER_TITLE", "Create Pull Request Review"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string", Description: "Repository owner (username or organization)"}, + "repo": {Type: "string", Description: "Repository name"}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)}, + "body": {Type: "string", Description: "The review body text (optional)"}, + "event": {Type: "string", Description: "The review action to perform. If omitted, creates a pending review.", Enum: []any{"APPROVE", "REQUEST_CHANGES", "COMMENT"}}, + "commitID": {Type: "string", Description: "The SHA of the commit to review (optional, defaults to latest)"}, + }, + Required: []string{"owner", "repo", "pullNumber"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pullNumber, err := RequiredInt(args, "pullNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, _ := OptionalParam[string](args, "body") + event, _ := OptionalParam[string](args, "event") + commitID, _ := OptionalParam[string](args, "commitID") + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + + var commitIDPtr *string + if commitID != "" { + commitIDPtr = &commitID + } + + result, err := CreatePullRequestReview(ctx, gqlClient, PullRequestReviewWriteParams{ + Owner: owner, + Repo: repo, + PullNumber: int32(pullNumber), // #nosec G115 - PR numbers are always small positive integers + Body: body, + Event: event, + CommitID: commitIDPtr, + }) + return result, nil, err + }, + ) + st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + return st +} + +// GranularSubmitPendingPullRequestReview creates a tool to submit a pending review. +func GranularSubmitPendingPullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "submit_pending_pull_request_review", + Description: t("TOOL_SUBMIT_PENDING_PULL_REQUEST_REVIEW_DESCRIPTION", "Submit a pending pull request review."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_SUBMIT_PENDING_PULL_REQUEST_REVIEW_USER_TITLE", "Submit Pending Pull Request Review"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string", Description: "Repository owner (username or organization)"}, + "repo": {Type: "string", Description: "Repository name"}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)}, + "event": {Type: "string", Description: "The review action to perform", Enum: []any{"APPROVE", "REQUEST_CHANGES", "COMMENT"}}, + "body": {Type: "string", Description: "The review body text (optional)"}, + }, + Required: []string{"owner", "repo", "pullNumber", "event"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pullNumber, err := RequiredInt(args, "pullNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + event, err := RequiredParam[string](args, "event") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, _ := OptionalParam[string](args, "body") + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + + result, err := SubmitPendingPullRequestReview(ctx, gqlClient, PullRequestReviewWriteParams{ + Owner: owner, + Repo: repo, + PullNumber: int32(pullNumber), // #nosec G115 - PR numbers are always small positive integers + Event: event, + Body: body, + }) + return result, nil, err + }, + ) + st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + return st +} + +// GranularDeletePendingPullRequestReview creates a tool to delete a pending review. +func GranularDeletePendingPullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "delete_pending_pull_request_review", + Description: t("TOOL_DELETE_PENDING_PULL_REQUEST_REVIEW_DESCRIPTION", "Delete a pending pull request review."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_DELETE_PENDING_PULL_REQUEST_REVIEW_USER_TITLE", "Delete Pending Pull Request Review"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string", Description: "Repository owner (username or organization)"}, + "repo": {Type: "string", Description: "Repository name"}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)}, + }, + Required: []string{"owner", "repo", "pullNumber"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pullNumber, err := RequiredInt(args, "pullNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + + result, err := DeletePendingPullRequestReview(ctx, gqlClient, PullRequestReviewWriteParams{ + Owner: owner, + Repo: repo, + PullNumber: int32(pullNumber), // #nosec G115 - PR numbers are always small positive integers + }) + return result, nil, err + }, + ) + st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + return st +} + +// GranularAddPullRequestReviewComment creates a tool to add a review comment. +func GranularAddPullRequestReviewComment(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "add_pull_request_review_comment", + Description: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_DESCRIPTION", "Add a review comment to the current user's pending pull request review."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_USER_TITLE", "Add Pull Request Review Comment"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string", Description: "Repository owner (username or organization)"}, + "repo": {Type: "string", Description: "Repository name"}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)}, + "path": {Type: "string", Description: "The relative path of the file to comment on"}, + "body": {Type: "string", Description: "The comment body"}, + "subjectType": {Type: "string", Description: "The subject type of the comment", Enum: []any{"FILE", "LINE"}}, + "line": {Type: "number", Description: "The line number in the diff to comment on (optional)"}, + "side": {Type: "string", Description: "The side of the diff to comment on (optional)", Enum: []any{"LEFT", "RIGHT"}}, + "startLine": {Type: "number", Description: "The start line of a multi-line comment (optional)"}, + "startSide": {Type: "string", Description: "The start side of a multi-line comment (optional)", Enum: []any{"LEFT", "RIGHT"}}, + }, + Required: []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pullNumber, err := RequiredInt(args, "pullNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + path, err := RequiredParam[string](args, "path") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, err := RequiredParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + subjectType, err := RequiredParam[string](args, "subjectType") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + line, err := OptionalIntParam(args, "line") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + side, _ := OptionalParam[string](args, "side") + startLine, err := OptionalIntParam(args, "startLine") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + startSide, _ := OptionalParam[string](args, "startSide") + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + + // Convert optional int params to *int32 for the helper + var linePtr, startLinePtr *int32 + if line != 0 { + l := int32(line) // #nosec G115 + linePtr = &l + } + if startLine != 0 { + sl := int32(startLine) // #nosec G115 + startLinePtr = &sl + } + + // Convert optional string params: pass nil (not empty string) when absent + var sidePtr, startSidePtr *string + if side != "" { + sidePtr = &side + } + if startSide != "" { + startSidePtr = &startSide + } + + result, err := AddCommentToPendingReviewCall(ctx, gqlClient, AddCommentToPendingReviewParams{ + Owner: owner, + Repo: repo, + PullNumber: int32(pullNumber), // #nosec G115 - PR numbers are always small positive integers + Path: path, + Body: body, + SubjectType: subjectType, + Line: linePtr, + Side: sidePtr, + StartLine: startLinePtr, + StartSide: startSidePtr, + }) + return result, nil, err + }, + ) + st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + return st +} + +// GranularResolveReviewThread creates a tool to resolve a review thread. +func GranularResolveReviewThread(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "resolve_review_thread", + Description: t("TOOL_RESOLVE_REVIEW_THREAD_DESCRIPTION", "Resolve a review thread on a pull request. Resolving an already-resolved thread is a no-op."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_RESOLVE_REVIEW_THREAD_USER_TITLE", "Resolve Review Thread"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "threadID": { + Type: "string", + Description: "The node ID of the review thread to resolve (e.g., PRRT_kwDOxxx)", + }, + }, + Required: []string{"threadID"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + threadID, err := RequiredParam[string](args, "threadID") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + + result, err := ResolveReviewThread(ctx, gqlClient, threadID, true) + return result, nil, err + }, + ) + st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + return st +} + +// GranularUnresolveReviewThread creates a tool to unresolve a review thread. +func GranularUnresolveReviewThread(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "unresolve_review_thread", + Description: t("TOOL_UNRESOLVE_REVIEW_THREAD_DESCRIPTION", "Unresolve a previously resolved review thread on a pull request. Unresolving an already-unresolved thread is a no-op."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UNRESOLVE_REVIEW_THREAD_USER_TITLE", "Unresolve Review Thread"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "threadID": { + Type: "string", + Description: "The node ID of the review thread to unresolve (e.g., PRRT_kwDOxxx)", + }, + }, + Required: []string{"threadID"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + threadID, err := RequiredParam[string](args, "threadID") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + + result, err := ResolveReviewThread(ctx, gqlClient, threadID, false) + return result, nil, err + }, + ) + st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + return st +} diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 801122dca8..82034dfe30 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -9,9 +9,8 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -50,6 +49,7 @@ func Test_GetPullRequest(t *testing.T) { User: &github.User{ Login: github.Ptr("testuser"), }, + AuthorAssociation: github.Ptr("MEMBER"), } tests := []struct { @@ -96,12 +96,12 @@ func Test_GetPullRequest(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) deps := BaseDeps{ Client: client, GQLClient: gqlClient, - RepoAccessCache: stubRepoAccessCache(gqlClient, 5*time.Minute), + RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute), Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), } handler := serverTool.Handler(deps) @@ -135,6 +135,7 @@ func Test_GetPullRequest(t *testing.T) { assert.Equal(t, tc.expectedPR.GetTitle(), returnedPR.Title) assert.Equal(t, tc.expectedPR.GetState(), returnedPR.State) assert.Equal(t, tc.expectedPR.GetHTMLURL(), returnedPR.HTMLURL) + assert.Equal(t, tc.expectedPR.GetAuthorAssociation(), returnedPR.AuthorAssociation) }) } } @@ -258,6 +259,24 @@ func Test_UpdatePullRequest(t *testing.T) { expectError: false, expectedPR: mockPRWithReviewers, }, + { + name: "successful PR update with user and team reviewers", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ + "reviewers": []any{"reviewer1"}, + "team_reviewers": []any{"platform"}, + }).andThen(mockResponse(t, http.StatusOK, mockPRWithReviewers)), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPRWithReviewers), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "reviewers": []any{"reviewer1", "owner/platform"}, + }, + expectError: false, + expectedPR: mockPRWithReviewers, + }, { name: "successful PR update (title only)", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -328,7 +347,7 @@ func Test_UpdatePullRequest(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(nil) deps := BaseDeps{ Client: client, @@ -512,7 +531,7 @@ 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(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR), })) gqlClient := githubv4.NewClient(tc.mockedClient) @@ -575,16 +594,18 @@ func Test_ListPullRequests(t *testing.T) { // Setup mock PRs for success case mockPRs := []*github.PullRequest{ { - Number: github.Ptr(42), - Title: github.Ptr("First PR"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Number: github.Ptr(42), + Title: github.Ptr("First PR"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + AuthorAssociation: github.Ptr("OWNER"), }, { - Number: github.Ptr(43), - Title: github.Ptr("Second PR"), - State: github.Ptr("closed"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/43"), + Number: github.Ptr(43), + Title: github.Ptr("Second PR"), + State: github.Ptr("closed"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/43"), + AuthorAssociation: github.Ptr("CONTRIBUTOR"), }, } @@ -642,7 +663,7 @@ func Test_ListPullRequests(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := ListPullRequests(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -678,9 +699,11 @@ func Test_ListPullRequests(t *testing.T) { assert.Equal(t, *tc.expectedPRs[0].Number, returnedPRs[0].Number) assert.Equal(t, *tc.expectedPRs[0].Title, returnedPRs[0].Title) assert.Equal(t, *tc.expectedPRs[0].State, returnedPRs[0].State) + assert.Equal(t, tc.expectedPRs[0].GetAuthorAssociation(), returnedPRs[0].AuthorAssociation) assert.Equal(t, *tc.expectedPRs[1].Number, returnedPRs[1].Number) assert.Equal(t, *tc.expectedPRs[1].Title, returnedPRs[1].Title) assert.Equal(t, *tc.expectedPRs[1].State, returnedPRs[1].State) + assert.Equal(t, tc.expectedPRs[1].GetAuthorAssociation(), returnedPRs[1].AuthorAssociation) }) } } @@ -760,7 +783,7 @@ func Test_MergePullRequest(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := MergePullRequest(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1039,7 +1062,7 @@ func Test_SearchPullRequests(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := SearchPullRequests(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1198,11 +1221,11 @@ func Test_GetPullRequestFiles(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, - RepoAccessCache: stubRepoAccessCache(githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), 5*time.Minute), + RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute), Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), } handler := serverTool.Handler(deps) @@ -1358,11 +1381,11 @@ func Test_GetPullRequestStatus(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, - RepoAccessCache: stubRepoAccessCache(githubv4.NewClient(nil), 5*time.Minute), + RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute), Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), } handler := serverTool.Handler(deps) @@ -1514,11 +1537,11 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, - RepoAccessCache: stubRepoAccessCache(githubv4.NewClient(nil), 5*time.Minute), + RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute), Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), } handler := serverTool.Handler(deps) @@ -1642,7 +1665,7 @@ func Test_UpdatePullRequestBranch(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := UpdatePullRequestBranch(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1688,6 +1711,11 @@ func Test_GetPullRequestComments(t *testing.T) { assert.Contains(t, schema.Properties, "owner") assert.Contains(t, schema.Properties, "repo") assert.Contains(t, schema.Properties, "pullNumber") + // `after` is required for cursor-based pagination on get_review_comments + // to be reachable from MCP clients; without it in the schema, callers + // cannot advance past the first page (issue #2122). + assert.Contains(t, schema.Properties, "after") + assert.Equal(t, "string", schema.Properties["after"].Type) assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"}) tests := []struct { @@ -1805,6 +1833,54 @@ func Test_GetPullRequestComments(t *testing.T) { assert.Equal(t, 1, result.TotalCount) }, }, + { + name: "after cursor is forwarded to GraphQL query", + gqlHTTPClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + reviewThreadsQuery{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(42), + "first": githubv4.Int(30), + "commentsPerThread": githubv4.Int(100), + "after": githubv4.String("cursor-page-2"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "reviewThreads": map[string]any{ + "nodes": []map[string]any{}, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": true, + "startCursor": "cursor3", + "endCursor": "cursor4", + }, + "totalCount": 5, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "method": "get_review_comments", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "after": "cursor-page-2", + }, + expectError: false, + validateResult: func(t *testing.T, textContent string) { + var result MinimalReviewThreadsResponse + err := json.Unmarshal([]byte(textContent), &result) + require.NoError(t, err) + assert.Len(t, result.ReviewThreads, 0) + assert.Equal(t, true, result.PageInfo.HasPreviousPage) + assert.Equal(t, "cursor4", result.PageInfo.EndCursor) + }, + }, { name: "review threads fetch fails", gqlHTTPClient: githubv4mock.NewMockedHTTPClient( @@ -1937,17 +2013,20 @@ func Test_GetPullRequestComments(t *testing.T) { } // Setup cache for lockdown mode - var cache *lockdown.RepoAccessCache + var restClient *github.Client if tc.lockdownEnabled { - cache = stubRepoAccessCache(githubv4.NewClient(newRepoAccessHTTPClient()), 5*time.Minute) - } else { - cache = stubRepoAccessCache(gqlClient, 5*time.Minute) + restClient = mockRESTPermissionServer(t, "read", map[string]string{ + "maintainer": "write", + "external-user": "read", + "testuser": "read", + }) } + cache := stubRepoAccessCache(restClient, 5*time.Minute) flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ - Client: github.NewClient(nil), + Client: mustNewGHClient(t, nil), GQLClient: gqlClient, RepoAccessCache: cache, Flags: flags, @@ -2048,13 +2127,39 @@ func Test_GetPullRequestReviews(t *testing.T) { expectError: false, expectedReviews: mockReviews, }, + { + name: "successful reviews fetch with pagination", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsReviewsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockReviews), + ), + }), + requestArgs: map[string]any{ + "method": "get_reviews", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "page": float64(2), + "perPage": float64(10), + }, + expectError: false, + expectedReviews: mockReviews, + }, { name: "reviews fetch fails", 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"}`)) - }), + GetReposPullsReviewsByOwnerByRepoByPullNumber: 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]any{ "method": "get_reviews", @@ -2083,7 +2188,6 @@ func Test_GetPullRequestReviews(t *testing.T) { }, }), }), - gqlHTTPClient: newRepoAccessHTTPClient(), requestArgs: map[string]any{ "method": "get_reviews", "owner": "owner", @@ -2106,14 +2210,15 @@ func Test_GetPullRequestReviews(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) - var gqlClient *githubv4.Client - if tc.gqlHTTPClient != nil { - gqlClient = githubv4.NewClient(tc.gqlHTTPClient) - } else { - gqlClient = githubv4.NewClient(nil) + client := mustNewGHClient(t, tc.mockedClient) + var restClient *github.Client + if tc.lockdownEnabled { + restClient = mockRESTPermissionServer(t, "read", map[string]string{ + "maintainer": "write", + "testuser": "read", + }) } - cache := stubRepoAccessCache(gqlClient, 5*time.Minute) + cache := stubRepoAccessCache(restClient, 5*time.Minute) flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ @@ -2272,7 +2377,7 @@ func Test_CreatePullRequest(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := CreatePullRequest(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -2312,9 +2417,9 @@ func Test_CreatePullRequest(t *testing.T) { } } -// Test_CreatePullRequest_InsidersMode_UIGate verifies the insiders mode UI gate +// Test_CreatePullRequest_MCPAppsFeature_UIGate verifies the MCP Apps feature UI gate // behavior: UI clients get a form message, non-UI clients execute directly. -func Test_CreatePullRequest_InsidersMode_UIGate(t *testing.T) { +func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { t.Parallel() mockPR := &github.PullRequest{ @@ -2328,14 +2433,14 @@ func Test_CreatePullRequest_InsidersMode_UIGate(t *testing.T) { serverTool := CreatePullRequest(translations.NullTranslationHelper) - client := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposPullsByOwnerByRepo: mockResponse(t, http.StatusCreated, mockPR), })) deps := BaseDeps{ - Client: client, - GQLClient: githubv4.NewClient(nil), - Flags: FeatureFlags{InsidersMode: true}, + Client: client, + GQLClient: githubv4.NewClient(nil), + featureChecker: featureCheckerFor(MCPAppsFeatureFlag), } handler := serverTool.Handler(deps) @@ -2386,6 +2491,49 @@ func Test_CreatePullRequest_InsidersMode_UIGate(t *testing.T) { assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", "non-UI client should execute directly") }) + + t.Run("UI client with non-form param skips form and executes directly", func(t *testing.T) { + // A parameter the form does not collect must bypass the form rather than + // be silently dropped. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "reviewers": []any{"octocat"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "Ready to create a pull request", + "non-form param should skip UI form") + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "non-form param call should execute directly and return PR URL") + }) +} + +func Test_pullRequestWriteHasNonFormParams(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args map[string]any + want bool + }{ + {name: "no params", args: map[string]any{}, want: false}, + {name: "only form params", args: map[string]any{"owner": "o", "repo": "r", "title": "t", "body": "b", "head": "h", "base": "b", "draft": true, "maintainer_can_modify": false, "_ui_submitted": true}, want: false}, + {name: "unknown param present", args: map[string]any{"title": "t", "reviewers": []any{"octocat"}}, want: true}, + {name: "nil value is ignored", args: map[string]any{"reviewers": nil}, want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, pullRequestWriteHasNonFormParams(tc.args)) + }) + } } func TestCreateAndSubmitPullRequestReview(t *testing.T) { @@ -3344,11 +3492,11 @@ index 5d6e7b2..8a4f5c3 100644 t.Parallel() // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, - RepoAccessCache: stubRepoAccessCache(githubv4.NewClient(nil), 5*time.Minute), + RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute), Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), } handler := serverTool.Handler(deps) @@ -3581,7 +3729,7 @@ func TestAddReplyToPullRequestComment(t *testing.T) { t.Parallel() // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := AddReplyToPullRequestComment(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 3051896509..040a968cf9 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -10,12 +10,13 @@ import ( "strings" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -45,10 +46,11 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { Type: "string", Description: "Commit SHA, branch name, or tag name", }, - "include_diff": { - Type: "boolean", - Description: "Whether to include file diffs and stats in the response. Default is true.", - Default: json.RawMessage(`true`), + "detail": { + Type: "string", + Enum: []any{"none", "stats", "full_patch"}, + Description: "Level of detail to include for changed files. \"none\" omits stats and files entirely. \"stats\" (default) includes per-file metadata: filename, status, and lines-of-code counts (additions, deletions, changes), with no patch content. \"full_patch\" additionally includes the unified diff content for each file and can be very large.", + Default: json.RawMessage(`"stats"`), }, }, Required: []string{"owner", "repo", "sha"}, @@ -68,7 +70,11 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - includeDiff, err := OptionalBoolParamWithDefault(args, "include_diff", true) + detailRaw, err := OptionalParam[string](args, "detail") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + detail, err := parseCommitDetail(detailRaw) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } @@ -105,7 +111,7 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { } // Convert to minimal commit - minimalCommit := convertToMinimalCommit(commit, includeDiff) + minimalCommit := convertToMinimalCommit(commit, detail) r, err := json.Marshal(minimalCommit) if err != nil { @@ -251,7 +257,7 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { // Convert to minimal commits minimalCommits := make([]MinimalCommit, len(commits)) for i, commit := range commits { - minimalCommits[i] = convertToMinimalCommit(commit, false) + minimalCommits[i] = convertToMinimalCommit(commit, commitDetailNone) } r, err := json.Marshal(minimalCommits) @@ -653,6 +659,20 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool ) } +// FetchRepoIsPrivate returns whether a repository is private. It is a thin +// wrapper around the GitHub Repositories.Get endpoint provided as a shared +// helper for IFC label computation across tools. +func FetchRepoIsPrivate(ctx context.Context, client *github.Client, owner, repo string) (bool, error) { + r, resp, err := client.Repositories.Get(ctx, owner, repo) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return false, err + } + return r.GetPrivate(), nil +} + // GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository. func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( @@ -725,6 +745,36 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultError("failed to get GitHub client"), nil, nil } + // attachIFC adds the IFC label to a successful tool result when + // IFC labels are enabled. The visibility lookup is performed + // lazily on first use and cached because GetFileContents has + // many possible return paths and would otherwise re-fetch on + // each. If the visibility lookup fails we skip the label rather + // than misclassify the result; the failure is not cached so a + // later return path can retry. + var ( + ifcLabelKnown bool + ifcIsPrivate bool + ) + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + return r + } + if !ifcLabelKnown { + isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo) + if err != nil { + return r + } + ifcIsPrivate = isPrivate + ifcLabelKnown = true + } + if r.Meta == nil { + r.Meta = mcp.Meta{} + } + r.Meta["ifc"] = ifc.LabelGetFileContents(ifcIsPrivate) + return r + } + rawOpts, fallbackUsed, err := resolveGitReference(ctx, client, owner, repo, ref, sha) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to resolve git reference: %s", err)), nil, nil @@ -746,7 +796,8 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool // The path does not point to a file or directory. // Instead let's try to find it in the Git Tree by matching the end of the path. if err != nil || (fileContent == nil && dirContent == nil) { - return matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0) + res, data, err := matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0) + return attachIFC(res), data, err } if fileContent != nil && fileContent.SHA != nil { @@ -776,7 +827,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Text: "", MIMEType: "text/plain", } - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote), result), nil, nil + return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil } // For files >= 1MB, return a ResourceLink instead of content @@ -789,10 +840,10 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Title: fmt.Sprintf("File: %s", path), Size: &size, } - return utils.NewToolResultResourceLink( + return attachIFC(utils.NewToolResultResourceLink( fmt.Sprintf("File %s is too large to display (%d bytes). Use the download URL to fetch the content: %s (SHA: %s)%s", path, fileSize, fileContent.GetDownloadURL(), fileSHA, successNote), - resourceLink), nil, nil + resourceLink)), nil, nil } // For files < 1MB, get content directly from Contents API @@ -820,7 +871,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Text: content, MIMEType: contentType, } - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote), result), nil, nil + return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil } // Binary content - encode as base64 blob @@ -830,14 +881,14 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Blob: []byte(blobContent), MIMEType: contentType, } - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote), result), nil, nil + return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil } else if dirContent != nil { // file content or file SHA is nil which means it's a directory r, err := json.Marshal(dirContent) if err != nil { return utils.NewToolResultError("failed to marshal response"), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return attachIFC(utils.NewToolResultText(string(r))), nil, nil } return utils.NewToolResultError("failed to get file contents"), nil, nil @@ -1272,7 +1323,8 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { Type: "array", Description: "Array of file objects to push, each object with path (string) and content (string)", Items: &jsonschema.Schema{ - Type: "object", + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, Properties: map[string]*jsonschema.Schema{ "path": { Type: "string", @@ -1631,7 +1683,15 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get tag reference", resp, body), nil, nil } - // Then get the tag object + // Differentiate between lightweight and annotated tags since lightweight ones don't have a fetchable object + if ref.Object.GetType() == "commit" { + r, err := json.Marshal(ref) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + return utils.NewToolResultText(string(r)), nil, nil + } + tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, @@ -2147,3 +2207,111 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool }, ) } + +// ListRepositoryCollaborators creates a tool to list collaborators of a GitHub repository. +func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "list_repository_collaborators", + Description: t("TOOL_LIST_REPOSITORY_COLLABORATORS_DESCRIPTION", "List collaborators of a GitHub repository. Results are paginated; the response includes `nextPage`, `prevPage`, `firstPage`, and `lastPage` fields. To get the next page, use the `nextPage` value as the `page` parameter."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_REPOSITORY_COLLABORATORS_USER_TITLE", "List repository collaborators"), + ReadOnlyHint: true, + }, + InputSchema: func() *jsonschema.Schema { + schema := WithPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "affiliation": { + Type: "string", + Description: "Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all'", + Enum: []any{"outside", "direct", "all"}, + }, + }, + Required: []string{"owner", "repo"}, + }) + schema.Properties["page"].Description = "Page number for pagination (default 1, min 1)" + schema.Properties["perPage"].Description = "Results per page for pagination (default 30, min 1, max 100)" + return schema + }(), + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + affiliation, err := OptionalParam[string](args, "affiliation") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + opts := &github.ListCollaboratorsOptions{ + Affiliation: affiliation, + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list collaborators", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list collaborators", resp, body), nil, nil + } + + result := make([]MinimalCollaborator, 0, len(collaborators)) + for _, c := range collaborators { + result = append(result, MinimalCollaborator{ + Login: c.GetLogin(), + ID: c.GetID(), + RoleName: c.GetRoleName(), + }) + } + + response := map[string]any{ + "items": result, + "nextPage": resp.NextPage, + "prevPage": resp.PrevPage, + "firstPage": resp.FirstPage, + "lastPage": resp.LastPage, + } + + return MarshalledTextResult(response), nil, nil + }, + ) +} diff --git a/pkg/github/repositories_helper.go b/pkg/github/repositories_helper.go index a347ebdd6c..be377f773e 100644 --- a/pkg/github/repositories_helper.go +++ b/pkg/github/repositories_helper.go @@ -10,7 +10,7 @@ import ( 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/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index d7bb487382..e1b7f94f53 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -14,7 +14,7 @@ import ( "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" @@ -412,8 +412,9 @@ func Test_GetFileContents(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) - mockRawClient := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"}) + client := mustNewGHClient(t, tc.mockedClient) + mockRawClient, err := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"}) + require.NoError(t, err) deps := BaseDeps{ Client: client, RawClient: mockRawClient, @@ -477,6 +478,148 @@ func Test_GetFileContents(t *testing.T) { } } +func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { + t.Parallel() + + serverTool := GetFileContents(translations.NullTranslationHelper) + + mockRawContent := []byte("hello") + + makeMockClient := func(isPrivate bool) *http.Client { + return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{ + "name": "repo", + "default_branch": "main", + "private": isPrivate, + }), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + encodedContent := base64.StdEncoding.EncodeToString(mockRawContent) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + Content: github.Ptr(encodedContent), + Size: github.Ptr(len(mockRawContent)), + Encoding: github.Ptr("base64"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + }) + } + + reqParams := map[string]any{ + "owner": "octocat", + "repo": "repo", + "path": "README.md", + "ref": "refs/heads/main", + } + + t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false)), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assert.Nil(t, result.Meta, "result meta should be nil when insiders mode is disabled") + }) + + t.Run("insiders mode enabled on public repo emits public untrusted label", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode enabled on private repo emits private trusted label", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(true)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + + assert.Equal(t, "trusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusInternalServerError, "boom"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + encodedContent := base64.StdEncoding.EncodeToString(mockRawContent) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + Content: github.Ptr(encodedContent), + Size: github.Ptr(len(mockRawContent)), + Encoding: github.Ptr("base64"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + }) + deps := BaseDeps{ + Client: mustNewGHClient(t, mockedClient), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails") + + if result.Meta != nil { + _, hasIFC := result.Meta["ifc"] + assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails") + } + }) +} + func Test_ForkRepository(t *testing.T) { // Verify tool definition once serverTool := ForkRepository(translations.NullTranslationHelper) @@ -547,7 +690,7 @@ func Test_ForkRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -719,7 +862,7 @@ func Test_CreateBranch(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -845,7 +988,7 @@ func Test_GetCommit(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -885,6 +1028,120 @@ func Test_GetCommit(t *testing.T) { } } +func Test_GetCommit_Detail(t *testing.T) { + mockCommit := &github.RepositoryCommit{ + SHA: github.Ptr("abc123def456"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + Commit: &github.Commit{ + Message: github.Ptr("First commit"), + }, + Stats: &github.CommitStats{ + Additions: github.Ptr(10), + Deletions: github.Ptr(2), + Total: github.Ptr(12), + }, + Files: []*github.CommitFile{ + { + Filename: github.Ptr("file1.go"), + Status: github.Ptr("modified"), + Additions: github.Ptr(10), + Deletions: github.Ptr(2), + Changes: github.Ptr(12), + Patch: github.Ptr("@@ -1,2 +1,10 @@\n+new line"), + }, + }, + } + + cases := []struct { + name string + args map[string]any + expectFiles bool + expectStats bool + expectPatch bool + expectError string + }{ + { + name: "default returns stats", + args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456"}, + expectFiles: true, + expectStats: true, + expectPatch: false, + }, + { + name: "detail=none omits stats and files", + args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456", "detail": "none"}, + expectFiles: false, + expectStats: false, + expectPatch: false, + }, + { + name: "detail=stats returns metadata without patch", + args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456", "detail": "stats"}, + expectFiles: true, + expectStats: true, + expectPatch: false, + }, + { + name: "detail=full_patch includes patch text", + args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456", "detail": "full_patch"}, + expectFiles: true, + expectStats: true, + expectPatch: true, + }, + { + name: "invalid detail value is rejected", + args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456", "detail": "everything"}, + expectError: `invalid detail "everything"`, + }, + } + + serverTool := GetCommit(translations.NullTranslationHelper) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit), + }) + client := mustNewGHClient(t, mockedClient) + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.args) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectError != "" { + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.expectError) + return + } + require.False(t, result.IsError) + + var returned MinimalCommit + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + + if tc.expectStats { + require.NotNil(t, returned.Stats) + assert.Equal(t, 12, returned.Stats.Total) + } else { + assert.Nil(t, returned.Stats) + } + + if tc.expectFiles { + require.Len(t, returned.Files, 1) + assert.Equal(t, "file1.go", returned.Files[0].Filename) + if tc.expectPatch { + assert.Equal(t, "@@ -1,2 +1,10 @@\n+new line", returned.Files[0].Patch) + } else { + assert.Empty(t, returned.Files[0].Patch) + } + } else { + assert.Empty(t, returned.Files) + } + }) + } +} + func Test_ListCommits(t *testing.T) { // Verify tool definition once serverTool := ListCommits(translations.NullTranslationHelper) @@ -1136,7 +1393,7 @@ func Test_ListCommits(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -1493,7 +1750,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -1682,7 +1939,7 @@ func Test_CreateRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2420,7 +2677,7 @@ func Test_PushFiles(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2541,7 +2798,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(NewMockedHTTPClient(tt.mockResponses...)) + mockClient := mustNewGHClient(t, NewMockedHTTPClient(tt.mockResponses...)) deps := BaseDeps{ Client: mockClient, } @@ -2729,7 +2986,7 @@ func Test_DeleteFile(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2856,7 +3113,7 @@ func Test_ListTags(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2914,10 +3171,19 @@ func Test_GetTag(t *testing.T) { assert.Contains(t, schema.Properties, "tag") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "tag"}) - mockTagRef := &github.Reference{ + mockAnnotatedTagRef := &github.Reference{ Ref: github.Ptr("refs/tags/v1.0.0"), Object: &github.GitObject{ - SHA: github.Ptr("v1.0.0-tag-sha"), + Type: github.Ptr("tag"), + SHA: github.Ptr("v1.0.0-tag-sha"), + }, + } + + mockLightweightTagRef := &github.Reference{ + Ref: github.Ptr("refs/tags/v1.0.1"), + Object: &github.GitObject{ + Type: github.Ptr("commit"), + SHA: github.Ptr("abc123"), }, } @@ -2937,6 +3203,7 @@ func Test_GetTag(t *testing.T) { requestArgs map[string]any expectError bool expectedTag *github.Tag + expectedRef *github.Reference expectedErrMsg string }{ { @@ -2948,7 +3215,7 @@ func Test_GetTag(t *testing.T) { t, "/repos/owner/repo/git/ref/tags/v1.0.0", ).andThen( - mockResponse(t, http.StatusOK, mockTagRef), + mockResponse(t, http.StatusOK, mockAnnotatedTagRef), ), ), WithRequestMatchHandler( @@ -2993,7 +3260,7 @@ func Test_GetTag(t *testing.T) { mockedClient: NewMockedHTTPClient( WithRequestMatch( GetReposGitRefByOwnerByRepoByRef, - mockTagRef, + mockAnnotatedTagRef, ), WithRequestMatchHandler( GetReposGitTagsByOwnerByRepoByTagSHA, @@ -3011,12 +3278,33 @@ func Test_GetTag(t *testing.T) { expectError: true, expectedErrMsg: "failed to get tag object", }, + { + name: "successful lightweight tag retrieval", + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, + expectPath( + t, + "/repos/owner/repo/git/ref/tags/v1.0.1", + ).andThen( + mockResponse(t, http.StatusOK, mockLightweightTagRef), + ), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "tag": "v1.0.1", + }, + expectError: false, + expectedRef: mockLightweightTagRef, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3043,16 +3331,29 @@ func Test_GetTag(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Parse and verify the result - var returnedTag github.Tag - err = json.Unmarshal([]byte(textContent.Text), &returnedTag) - require.NoError(t, err) + // Parse and verify the result - annotated tag (full tag object) + if tc.expectedTag != nil { + var returnedTag github.Tag + err = json.Unmarshal([]byte(textContent.Text), &returnedTag) + require.NoError(t, err) - assert.Equal(t, *tc.expectedTag.SHA, *returnedTag.SHA) - assert.Equal(t, *tc.expectedTag.Tag, *returnedTag.Tag) - assert.Equal(t, *tc.expectedTag.Message, *returnedTag.Message) - assert.Equal(t, *tc.expectedTag.Object.Type, *returnedTag.Object.Type) - assert.Equal(t, *tc.expectedTag.Object.SHA, *returnedTag.Object.SHA) + assert.Equal(t, tc.expectedTag.GetSHA(), returnedTag.GetSHA()) + assert.Equal(t, tc.expectedTag.GetTag(), returnedTag.GetTag()) + assert.Equal(t, tc.expectedTag.GetMessage(), returnedTag.GetMessage()) + assert.Equal(t, tc.expectedTag.Object.GetType(), returnedTag.Object.GetType()) + assert.Equal(t, tc.expectedTag.Object.GetSHA(), returnedTag.Object.GetSHA()) + } + + // Parse and verify the result - lightweight tag (reference only) + if tc.expectedRef != nil { + var returnedRef github.Reference + err = json.Unmarshal([]byte(textContent.Text), &returnedRef) + require.NoError(t, err) + + assert.Equal(t, tc.expectedRef.GetRef(), returnedRef.GetRef()) + assert.Equal(t, tc.expectedRef.Object.GetType(), returnedRef.Object.GetType()) + assert.Equal(t, tc.expectedRef.Object.GetSHA(), returnedRef.Object.GetSHA()) + } }) } } @@ -3129,7 +3430,7 @@ func Test_ListReleases(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3155,6 +3456,7 @@ func Test_ListReleases(t *testing.T) { }) } } + func Test_GetLatestRelease(t *testing.T) { serverTool := GetLatestRelease(translations.NullTranslationHelper) tool := serverTool.Tool @@ -3220,7 +3522,7 @@ func Test_GetLatestRelease(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3368,7 +3670,7 @@ func Test_GetReleaseByTag(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3813,7 +4115,7 @@ func Test_resolveGitReference(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockSetup()) + client := mustNewGHClient(t, tc.mockSetup()) opts, _, err := resolveGitReference(ctx, client, owner, repo, tc.ref, tc.sha) if tc.expectError { @@ -3959,7 +4261,7 @@ func Test_ListStarredRepositories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -4060,7 +4362,7 @@ func Test_StarRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -4151,7 +4453,7 @@ func Test_UnstarRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -4180,3 +4482,149 @@ func Test_UnstarRepository(t *testing.T) { }) } } + +func Test_ListRepositoryCollaborators(t *testing.T) { + // Verify tool definition once + serverTool := ListRepositoryCollaborators(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + assert.Equal(t, "list_repository_collaborators", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.True(t, tool.Annotations.ReadOnlyHint) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "affiliation") + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) + + mockCollaborators := []*github.User{ + { + Login: github.Ptr("user1"), + ID: github.Ptr(int64(101)), + RoleName: github.Ptr("admin"), + }, + { + Login: github.Ptr("user2"), + ID: github.Ptr(int64(102)), + RoleName: github.Ptr("write"), + }, + } + + tests := []struct { + name string + args map[string]any + mockResponses []MockBackendOption + wantErr bool + errContains string + }{ + { + name: "success", + args: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + mockResponses: []MockBackendOption{ + WithRequestMatch( + ListCollaborators, + mockCollaborators, + ), + }, + }, + { + name: "success with affiliation filter", + args: map[string]any{ + "owner": "owner", + "repo": "repo", + "affiliation": "direct", + }, + mockResponses: []MockBackendOption{ + WithRequestMatch( + ListCollaborators, + mockCollaborators, + ), + }, + }, + { + name: "missing owner", + args: map[string]any{ + "repo": "repo", + }, + mockResponses: []MockBackendOption{}, + errContains: "missing required parameter: owner", + }, + { + name: "missing repo", + args: map[string]any{ + "owner": "owner", + }, + mockResponses: []MockBackendOption{}, + errContains: "missing required parameter: repo", + }, + { + name: "empty collaborators returns empty array", + args: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + mockResponses: []MockBackendOption{ + WithRequestMatch( + ListCollaborators, + []*github.User{}, + ), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := mustNewGHClient(t, NewMockedHTTPClient(tt.mockResponses...)) + deps := BaseDeps{ + Client: mockClient, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(tt.args) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.NotNil(t, result) + + if tt.errContains != "" { + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tt.errContains) + return + } + + textContent := getTextResult(t, result) + require.NotEmpty(t, textContent.Text) + + var response struct { + Items []MinimalCollaborator `json:"items"` + NextPage int `json:"nextPage"` + PrevPage int `json:"prevPage"` + FirstPage int `json:"firstPage"` + LastPage int `json:"lastPage"` + } + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + if tt.name == "empty collaborators returns empty array" { + assert.Empty(t, response.Items) + return + } + + collaborators := response.Items + assert.Len(t, collaborators, 2) + assert.Equal(t, "user1", collaborators[0].Login) + assert.Equal(t, int64(101), collaborators[0].ID) + assert.Equal(t, "admin", collaborators[0].RoleName) + assert.Equal(t, "user2", collaborators[1].Login) + assert.Equal(t, int64(102), collaborators[1].ID) + assert.Equal(t, "write", collaborators[1].RoleName) + }) + } +} diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index be86cc4519..3ab4cf3906 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -17,7 +17,7 @@ import ( "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/yosida95/uritemplate/v3" ) diff --git a/pkg/github/repository_resource_completions.go b/pkg/github/repository_resource_completions.go index ff9e23398a..18e7eb5f01 100644 --- a/pkg/github/repository_resource_completions.go +++ b/pkg/github/repository_resource_completions.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/repository_resource_completions_test.go b/pkg/github/repository_resource_completions_test.go index e5f1a35f93..33df2761e6 100644 --- a/pkg/github/repository_resource_completions_test.go +++ b/pkg/github/repository_resource_completions_test.go @@ -6,7 +6,7 @@ import ( "fmt" "testing" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go index f0fba30dfb..cb57bae545 100644 --- a/pkg/github/repository_resource_test.go +++ b/pkg/github/repository_resource_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/github/github-mcp-server/pkg/raw" - "github.com/google/go-github/v82/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) @@ -246,8 +245,9 @@ func Test_repositoryResourceContents(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - mockRawClient := raw.NewClient(client, base) + client := mustNewGHClient(t, tc.mockedClient) + mockRawClient, err := raw.NewClient(client, base) + require.NoError(t, err) deps := BaseDeps{ Client: client, RawClient: mockRawClient, @@ -290,8 +290,9 @@ func Test_repositoryResourceContentsHandler_NetworkError(t *testing.T) { networkErr := errors.New("network error: connection refused") httpClient := &http.Client{Transport: &errorTransport{err: networkErr}} - client := github.NewClient(httpClient) - mockRawClient := raw.NewClient(client, base) + client := mustNewGHClient(t, httpClient) + mockRawClient, err := raw.NewClient(client, base) + require.NoError(t, err) deps := BaseDeps{ Client: client, RawClient: mockRawClient, diff --git a/pkg/github/search.go b/pkg/github/search.go index d5ddb4a72a..9a8d182887 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -8,11 +8,12 @@ import ( "net/http" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -161,11 +162,37 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo } } - return utils.NewToolResultText(string(r)), nil, nil + callResult := utils.NewToolResultText(string(r)) + if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + attachSearchRepositoriesIFCLabel(result.Repositories, callResult) + } + return callResult, nil, nil }, ) } +// attachSearchRepositoriesIFCLabel joins per-repository IFC labels across +// every matched repository and attaches the result to callResult. Visibility +// is read directly from the search response — no extra API call. The join +// math is shared with search_issues via ifc.LabelSearchIssues: integrity is +// always untrusted; confidentiality is private if any matched repository is +// private, otherwise public. +func attachSearchRepositoriesIFCLabel(repos []*github.Repository, callResult *mcp.CallToolResult) { + if callResult == nil || callResult.IsError { + return + } + + visibilities := make([]bool, 0, len(repos)) + for _, repo := range repos { + visibilities = append(visibilities, repo.GetPrivate()) + } + + if callResult.Meta == nil { + callResult.Meta = mcp.Meta{} + } + callResult.Meta["ifc"] = ifc.LabelSearchIssues(visibilities) +} + // SearchCode creates a tool to search for code across GitHub repositories. func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ @@ -173,7 +200,7 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { Properties: map[string]*jsonschema.Schema{ "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 (GitHub code search REST). Implicit AND between terms; supports `OR`, `NOT`, and `\"quoted phrase\"` for exact match. Qualifiers: `repo:owner/repo`, `org:`, `user:`, `language:`, `path:dir` (prefix match), `filename:exact.ext`, `extension:`, `in:file`, `in:path`, `size:`, `is:archived`, `is:fork`. Max 256 chars. Examples: `WithContext language:go org:github`; `\"package main\" repo:o/r`; `func extension:go path:cmd repo:o/r`; `NOT TODO language:go repo:o/r`.", }, "sort": { Type: "string", @@ -220,8 +247,9 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { } opts := &github.SearchOptions{ - Sort: sort, - Order: order, + Sort: sort, + Order: order, + TextMatch: true, ListOptions: github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, @@ -251,7 +279,27 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search code", resp, body), nil, nil } - r, err := json.Marshal(result) + minimalItems := make([]MinimalCodeResult, 0, len(result.CodeResults)) + for _, code := range result.CodeResults { + item := MinimalCodeResult{ + Name: code.GetName(), + Path: code.GetPath(), + SHA: code.GetSHA(), + TextMatches: code.TextMatches, + } + if code.Repository != nil { + item.Repository = code.Repository.GetFullName() + } + minimalItems = append(minimalItems, item) + } + + minimalResult := &MinimalCodeSearchResult{ + TotalCount: result.GetTotal(), + IncompleteResults: result.GetIncompleteResults(), + Items: minimalItems, + } + + r, err := json.Marshal(minimalResult) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } @@ -430,3 +478,109 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) } + +// SearchCommits creates a tool to search for commits across GitHub repositories. +func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "query": { + Type: "string", + Description: "Commit search query (GitHub commit search REST). Searches commit messages on the default branch only. Scope the search with `repo:owner/repo`, `org:`, or `user:` (queries without a scope qualifier match across all of GitHub and are usually not what you want). Other qualifiers: `author:`, `committer:`, `author-name:`, `committer-name:`, `author-email:`, `committer-email:`, `author-date:`, `committer-date:` (supports `>`, `<`, `>=`, `<=`, and `YYYY-MM-DD..YYYY-MM-DD` ranges), `merge:true|false`, `hash:`, `tree:`, `parent:`, `is:public`. Examples: `repo:owner/repo fix panic`; `org:github author:defunkt committer-date:>=2024-01-01`; `\"refactor cache\" repo:o/r`; `hash:abc1234 repo:o/r`.", + }, + "sort": { + Type: "string", + Description: "Sort by author or committer date (defaults to best match)", + Enum: []any{"author-date", "committer-date"}, + }, + "order": { + Type: "string", + Description: "Sort order", + Enum: []any{"asc", "desc"}, + }, + }, + Required: []string{"query"}, + } + WithPagination(schema) + + return NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "search_commits", + Description: t("TOOL_SEARCH_COMMITS_DESCRIPTION", "Search for commits across GitHub repositories using GitHub's commit search syntax. Useful for finding specific changes, authors, or messages across one or many repositories. Searches the default branch only."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_SEARCH_COMMITS_USER_TITLE", "Search commits"), + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + query, err := RequiredParam[string](args, "query") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + sort, err := OptionalParam[string](args, "sort") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + order, err := OptionalParam[string](args, "order") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + opts := &github.SearchOptions{ + Sort: sort, + Order: order, + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + result, resp, err := client.Search.Commits(ctx, query, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to search commits with query '%s'", query), + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search commits", resp, body), nil, nil + } + + minimalCommits := make([]MinimalCommitSearchItem, 0, len(result.Commits)) + for _, commit := range result.Commits { + minimalCommits = append(minimalCommits, convertCommitResultToMinimalCommit(commit)) + } + + minimalResult := &MinimalSearchCommitsResult{ + TotalCount: result.GetTotal(), + IncompleteResults: result.GetIncompleteResults(), + Items: minimalCommits, + } + + r, err := json.Marshal(minimalResult) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) +} diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index 85eb21bcb5..fa48bf19a1 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -5,10 +5,11 @@ import ( "encoding/json" "net/http" "testing" + "time" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -123,7 +124,7 @@ func Test_SearchRepositories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -163,9 +164,118 @@ func Test_SearchRepositories(t *testing.T) { assert.Equal(t, *tc.expectedResult.Repositories[i].FullName, repo.FullName) assert.Equal(t, *tc.expectedResult.Repositories[i].HTMLURL, repo.HTMLURL) } + }) + } +} + +func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { + t.Parallel() + + serverTool := SearchRepositories(translations.NullTranslationHelper) + + type repoFixture struct { + owner string + name string + isPrivate bool + } + makeRepo := func(r repoFixture) *github.Repository { + return &github.Repository{ + ID: github.Ptr(int64(1)), + Name: github.Ptr(r.name), + FullName: github.Ptr(r.owner + "/" + r.name), + Private: github.Ptr(r.isPrivate), + Owner: &github.User{Login: github.Ptr(r.owner)}, + } + } + + makeMockClient := func(repos []repoFixture) *http.Client { + searchResult := &github.RepositoriesSearchResult{ + Total: github.Ptr(len(repos)), + IncompleteResults: github.Ptr(false), + } + for _, r := range repos { + searchResult.Repositories = append(searchResult.Repositories, makeRepo(r)) + } + return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchRepositories: mockResponse(t, http.StatusOK, searchResult), }) } + + reqParams := map[string]any{"query": "octocat"} + + t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient([]repoFixture{{owner: "octocat", name: "public-repo"}})), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Nil(t, result.Meta) + }) + + t.Run("insiders mode all public emits public untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient([]repoFixture{ + {owner: "octocat", name: "public-a"}, + {owner: "octocat", name: "public-b"}, + })), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode any private match emits private untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient([]repoFixture{ + {owner: "octocat", name: "private-repo", isPrivate: true}, + {owner: "octocat", name: "public-repo"}, + })), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(nil)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) } func Test_SearchRepositories_FullOutput(t *testing.T) { @@ -194,7 +304,7 @@ func Test_SearchRepositories_FullOutput(t *testing.T) { ), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) serverTool := SearchRepositories(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -252,22 +362,35 @@ func Test_SearchCode(t *testing.T) { IncompleteResults: github.Ptr(false), CodeResults: []*github.CodeResult{ { - Name: github.Ptr("file1.go"), - Path: github.Ptr("path/to/file1.go"), - SHA: github.Ptr("abc123def456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/path/to/file1.go"), - Repository: &github.Repository{Name: github.Ptr("repo"), FullName: github.Ptr("owner/repo")}, + Name: github.Ptr("file1.go"), + Path: github.Ptr("path/to/file1.go"), + SHA: github.Ptr("abc123def456"), + Repository: &github.Repository{ + Name: github.Ptr("repo"), + FullName: github.Ptr("owner/repo"), + }, + TextMatches: []*github.TextMatch{ + { + Fragment: github.Ptr("func main() { fmt.Println(\"hello\") }"), + }, + }, }, { - Name: github.Ptr("file2.go"), - Path: github.Ptr("path/to/file2.go"), - SHA: github.Ptr("def456abc123"), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/path/to/file2.go"), - Repository: &github.Repository{Name: github.Ptr("repo"), FullName: github.Ptr("owner/repo")}, + Name: github.Ptr("file2.go"), + Path: github.Ptr("path/to/file2.go"), + SHA: github.Ptr("def456abc123"), + Repository: &github.Repository{ + Name: github.Ptr("repo"), + FullName: github.Ptr("owner/repo"), + }, }, }, } + textMatchAcceptHeader := map[string]string{ + "Accept": "text-match", + } + tests := []struct { name string mockedClient *http.Client @@ -285,7 +408,7 @@ func Test_SearchCode(t *testing.T) { "order": "desc", "page": "1", "per_page": "30", - }).andThen( + }).withHeaders(textMatchAcceptHeader).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), }), @@ -306,7 +429,7 @@ func Test_SearchCode(t *testing.T) { "q": "fmt.Println language:go", "page": "1", "per_page": "30", - }).andThen( + }).withHeaders(textMatchAcceptHeader).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), }), @@ -335,7 +458,7 @@ func Test_SearchCode(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -359,22 +482,28 @@ func Test_SearchCode(t *testing.T) { require.NoError(t, err) require.False(t, result.IsError) - // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedResult github.CodeSearchResult + var returnedResult MinimalCodeSearchResult err = json.Unmarshal([]byte(textContent.Text), &returnedResult) require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) - assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) - assert.Len(t, returnedResult.CodeResults, len(tc.expectedResult.CodeResults)) - for i, code := range returnedResult.CodeResults { - assert.Equal(t, *tc.expectedResult.CodeResults[i].Name, *code.Name) - assert.Equal(t, *tc.expectedResult.CodeResults[i].Path, *code.Path) - assert.Equal(t, *tc.expectedResult.CodeResults[i].SHA, *code.SHA) - assert.Equal(t, *tc.expectedResult.CodeResults[i].HTMLURL, *code.HTMLURL) - assert.Equal(t, *tc.expectedResult.CodeResults[i].Repository.FullName, *code.Repository.FullName) + assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) + assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) + assert.Len(t, returnedResult.Items, len(tc.expectedResult.CodeResults)) + for i, code := range returnedResult.Items { + assert.Equal(t, tc.expectedResult.CodeResults[i].GetName(), code.Name) + assert.Equal(t, tc.expectedResult.CodeResults[i].GetPath(), code.Path) + assert.Equal(t, tc.expectedResult.CodeResults[i].GetSHA(), code.SHA) + assert.Equal(t, tc.expectedResult.CodeResults[i].Repository.GetFullName(), code.Repository) + } + + // Verify text matches are included when present + if len(tc.expectedResult.CodeResults[0].TextMatches) > 0 { + require.NotEmpty(t, returnedResult.Items[0].TextMatches) + assert.Equal(t, + tc.expectedResult.CodeResults[0].TextMatches[0].GetFragment(), + returnedResult.Items[0].TextMatches[0].GetFragment(), + ) } }) } @@ -520,7 +649,7 @@ func Test_SearchUsers(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -683,7 +812,7 @@ func Test_SearchOrgs(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -725,3 +854,162 @@ func Test_SearchOrgs(t *testing.T) { }) } } + +func Test_SearchCommits(t *testing.T) { + serverTool := SearchCommits(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "search_commits", tool.Name) + assert.NotEmpty(t, tool.Description) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "query") + assert.Contains(t, schema.Properties, "sort") + assert.Contains(t, schema.Properties, "order") + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") + assert.ElementsMatch(t, schema.Required, []string{"query"}) + + now := time.Now().Truncate(time.Second) + mockSearchResult := &github.CommitsSearchResult{ + Total: github.Ptr(2), + IncompleteResults: github.Ptr(false), + Commits: []*github.CommitResult{ + { + SHA: github.Ptr("abc123commit"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123commit"), + Commit: &github.Commit{ + Message: github.Ptr("Initial commit"), + Author: &github.CommitAuthor{ + Name: github.Ptr("Author Name"), + Email: github.Ptr("author@example.com"), + Date: &github.Timestamp{Time: now}, + }, + }, + Author: &github.User{ + Login: github.Ptr("author"), + ID: github.Ptr(int64(1)), + HTMLURL: github.Ptr("https://github.com/author"), + }, + Repository: &github.Repository{ + FullName: github.Ptr("owner/repo"), + HTMLURL: github.Ptr("https://github.com/owner/repo"), + Private: github.Ptr(false), + }, + }, + { + // Commit with no resolved GitHub user for author or committer + // (common when the commit email isn't linked to an account). + SHA: github.Ptr("def456commit"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456commit"), + Commit: &github.Commit{ + Message: github.Ptr("Unlinked author"), + }, + Repository: &github.Repository{ + FullName: github.Ptr("owner/repo"), + }, + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedResult *github.CommitsSearchResult + expectedErrMsg string + }{ + { + name: "successful commit search", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCommits: expectQueryParams(t, map[string]string{ + "q": "fix bug in:message repo:owner/repo", + "sort": "author-date", + "order": "desc", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + }), + requestArgs: map[string]any{ + "query": "fix bug in:message repo:owner/repo", + "sort": "author-date", + "order": "desc", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "search fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCommits: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + }), + requestArgs: map[string]any{ + "query": "invalid:syntax", + }, + expectError: true, + expectedErrMsg: "failed to search commits", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(tc.requestArgs) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var returnedResult MinimalSearchCommitsResult + err = json.Unmarshal([]byte(textContent.Text), &returnedResult) + require.NoError(t, err) + + assert.Equal(t, tc.expectedResult.GetTotal(), returnedResult.TotalCount) + assert.Len(t, returnedResult.Items, len(tc.expectedResult.Commits)) + assert.Equal(t, *tc.expectedResult.Commits[0].SHA, returnedResult.Items[0].SHA) + assert.Equal(t, *tc.expectedResult.Commits[0].Commit.Message, returnedResult.Items[0].Commit.Message) + assert.Equal(t, *tc.expectedResult.Commits[0].Commit.Author.Name, returnedResult.Items[0].Commit.Author.Name) + assert.Equal(t, now.Format(time.RFC3339), returnedResult.Items[0].Commit.Author.Date) + assert.Equal(t, *tc.expectedResult.Commits[0].Author.Login, returnedResult.Items[0].Author.Login) + + // Repository info is required so callers can identify which repo + // each cross-repo search result belongs to. + require.NotNil(t, returnedResult.Items[0].Repository) + assert.Equal(t, "owner/repo", returnedResult.Items[0].Repository.FullName) + assert.Equal(t, "https://github.com/owner/repo", returnedResult.Items[0].Repository.HTMLURL) + + // Second commit has no resolved GitHub user for author/committer + // and no commit-level author block — the handler must not panic + // and must omit those fields cleanly. + require.Len(t, returnedResult.Items, 2) + assert.Equal(t, "def456commit", returnedResult.Items[1].SHA) + assert.Nil(t, returnedResult.Items[1].Author) + assert.Nil(t, returnedResult.Items[1].Committer) + require.NotNil(t, returnedResult.Items[1].Commit) + assert.Nil(t, returnedResult.Items[1].Commit.Author) + assert.Nil(t, returnedResult.Items[1].Commit.Committer) + }) + } +} diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index c5502f6308..54213a2407 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -7,10 +7,11 @@ import ( "io" "net/http" "regexp" + "strings" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -37,16 +38,30 @@ func hasTypeFilter(query string) bool { return hasFilter(query, "type") } -func searchHandler( - ctx context.Context, - getClient GetClientFn, - args map[string]any, - searchType string, - errorPrefix string, -) (*mcp.CallToolResult, error) { +// searchPostProcessFn is invoked after a successful search response, before +// the call result is returned. It may attach additional metadata (such as IFC +// labels) to the call result based on the search payload. +type searchPostProcessFn func(ctx context.Context, result *github.IssuesSearchResult, callResult *mcp.CallToolResult) + +type searchConfig struct { + postProcess searchPostProcessFn +} + +type searchOption func(*searchConfig) + +// withSearchPostProcess registers a callback invoked after a successful search +// response. The callback may mutate the call result (e.g. to attach _meta.ifc). +func withSearchPostProcess(fn searchPostProcessFn) searchOption { + return func(c *searchConfig) { c.postProcess = fn } +} + +// prepareSearchArgs resolves the search query string and REST search options from the tool args, +// applying the standard is: / repo:/ munging shared by search_issues and +// search_pull_requests. +func prepareSearchArgs(args map[string]any, searchType string) (string, *github.SearchOptions, error) { query, err := RequiredParam[string](args, "query") if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } if !hasSpecificFilter(query, "is", searchType) { @@ -55,12 +70,12 @@ func searchHandler( owner, err := OptionalParam[string](args, "owner") if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } repo, err := OptionalParam[string](args, "repo") if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } if owner != "" && repo != "" && !hasRepoFilter(query) { @@ -69,19 +84,18 @@ func searchHandler( sort, err := OptionalParam[string](args, "sort") if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } order, err := OptionalParam[string](args, "order") if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } pagination, err := OptionalPaginationParams(args) if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } opts := &github.SearchOptions{ - // Default to "created" if no sort is provided, as it's a common use case. Sort: sort, Order: order, ListOptions: github.ListOptions{ @@ -90,6 +104,31 @@ func searchHandler( }, } + // field.: qualifiers require the advanced search API. + if strings.Contains(query, "field.") { + opts.AdvancedSearch = github.Ptr(true) + } + + return query, opts, nil +} + +func searchHandler( + ctx context.Context, + getClient GetClientFn, + args map[string]any, + searchType string, + errorPrefix string, + options ...searchOption, +) (*mcp.CallToolResult, error) { + cfg := searchConfig{} + for _, opt := range options { + opt(&cfg) + } + query, opts, err := prepareSearchArgs(args, searchType) + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), nil @@ -113,5 +152,9 @@ func searchHandler( return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil } - return utils.NewToolResultText(string(r)), nil + callResult := utils.NewToolResultText(string(r)) + if cfg.postProcess != nil { + cfg.postProcess(ctx, result, callResult) + } + return callResult, nil } diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index 676c2c1625..e2605274f0 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -12,7 +12,7 @@ import ( "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/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -95,6 +95,36 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv } func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "state": { + Type: "string", + Description: "Filter by state", + Enum: []any{"open", "resolved"}, + }, + "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.", + }, + "resolution": { + Type: "string", + Description: "Filter by resolution", + Enum: []any{"false_positive", "wont_fix", "revoked", "pattern_edited", "pattern_deleted", "used_in_tests"}, + }, + }, + Required: []string{"owner", "repo"}, + } + WithPagination(schema) + return NewTool( ToolsetMetadataSecretProtection, mcp.Tool{ @@ -104,34 +134,7 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se Title: t("TOOL_LIST_SECRET_SCANNING_ALERTS_USER_TITLE", "List secret scanning alerts"), ReadOnlyHint: true, }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "The owner of the repository.", - }, - "repo": { - Type: "string", - Description: "The name of the repository.", - }, - "state": { - Type: "string", - Description: "Filter by state", - Enum: []any{"open", "resolved"}, - }, - "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.", - }, - "resolution": { - Type: "string", - Description: "Filter by resolution", - Enum: []any{"false_positive", "wont_fix", "revoked", "pattern_edited", "pattern_deleted", "used_in_tests"}, - }, - }, - Required: []string{"owner", "repo"}, - }, + InputSchema: schema, }, []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -156,11 +159,24 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se return utils.NewToolResultError(err.Error()), nil, nil } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - alerts, resp, err := client.SecretScanning.ListAlertsForRepo(ctx, owner, repo, &github.SecretScanningAlertListOptions{State: state, SecretType: secretType, Resolution: resolution}) + alerts, resp, err := client.SecretScanning.ListAlertsForRepo(ctx, owner, repo, &github.SecretScanningAlertListOptions{ + State: state, + SecretType: secretType, + Resolution: resolution, + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go index 7c53de35c5..eb94fa5e9a 100644 --- a/pkg/github/secret_scanning_test.go +++ b/pkg/github/secret_scanning_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -79,7 +79,7 @@ func Test_GetSecretScanningAlert(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -165,7 +165,9 @@ func Test_ListSecretScanningAlerts(t *testing.T) { name: "successful resolved alerts listing", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposSecretScanningAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ - "state": "resolved", + "state": "resolved", + "page": "1", + "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, []*github.SecretScanningAlert{&resolvedAlert}), ), @@ -181,7 +183,10 @@ func Test_ListSecretScanningAlerts(t *testing.T) { { name: "successful alerts listing", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposSecretScanningAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{}).andThen( + GetReposSecretScanningAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( mockResponse(t, http.StatusOK, []*github.SecretScanningAlert{&resolvedAlert, &openAlert}), ), }), @@ -192,6 +197,25 @@ func Test_ListSecretScanningAlerts(t *testing.T) { expectError: false, expectedAlerts: []*github.SecretScanningAlert{&resolvedAlert, &openAlert}, }, + { + name: "successful alerts listing with custom pagination", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposSecretScanningAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "50", + }).andThen( + mockResponse(t, http.StatusOK, []*github.SecretScanningAlert{&openAlert}), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "page": float64(2), + "perPage": float64(50), + }, + expectError: false, + expectedAlerts: []*github.SecretScanningAlert{&openAlert}, + }, { name: "alerts listing fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -211,7 +235,7 @@ func Test_ListSecretScanningAlerts(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index e86e220eaf..ec84e27b15 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -12,7 +12,7 @@ import ( "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/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go index 3d4df43e63..f45c2e4210 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -92,7 +92,7 @@ func Test_ListGlobalSecurityAdvisories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -204,7 +204,7 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -337,7 +337,7 @@ func Test_ListRepositorySecurityAdvisories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -467,7 +467,7 @@ func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) diff --git a/pkg/github/server.go b/pkg/github/server.go index ee41e90e9e..f56ac7d3a8 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -38,10 +38,6 @@ type MCPServerConfig struct { // Items with FeatureFlagEnable matching an entry in this list will be available EnabledFeatures []string - // Whether to enable dynamic toolsets - // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery - DynamicToolsets bool - // ReadOnly indicates if we should only offer read-only tools ReadOnly bool @@ -54,7 +50,7 @@ type MCPServerConfig struct { // LockdownMode indicates if we should enable lockdown mode LockdownMode bool - // InsidersMode indicates if we should enable experimental features + // InsidersMode expands to the curated set of feature flags enabled for insiders. InsidersMode bool // Logger is used for logging within the server @@ -91,16 +87,6 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci o(serverOpts) } - // In dynamic mode, explicitly advertise capabilities since tools/resources/prompts - // may be enabled at runtime even if none are registered initially. - if cfg.DynamicToolsets { - serverOpts.Capabilities = &mcp.ServerCapabilities{ - Tools: &mcp.ToolCapabilities{}, - Resources: &mcp.ResourceCapabilities{}, - Prompts: &mcp.PromptCapabilities{}, - } - } - ghServer := NewServer(cfg.Version, cfg.Translator("SERVER_NAME", "github-mcp-server"), cfg.Translator("SERVER_TITLE", "GitHub MCP Server"), serverOpts) // Add middlewares. Order matters - for example, the error context middleware should be applied last so that it runs FIRST (closest to the handler) to ensure all errors are captured, @@ -114,49 +100,29 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci } // Register GitHub tools/resources/prompts from the inventory. - // In dynamic mode with no explicit toolsets, this is a no-op since enabledToolsets - // is empty - users enable toolsets at runtime via the dynamic tools below (but can - // enable toolsets or tools explicitly that do need registration). inv.RegisterAll(ctx, ghServer, deps) - // Register dynamic toolset management tools (enable/disable) - these are separate - // meta-tools that control the inventory, not part of the inventory itself - if cfg.DynamicToolsets { - registerDynamicTools(ghServer, inv, deps, cfg.Translator) + // Register MCP App UI resources whenever the embedded UI assets are + // available. The resources are static HTML and are only referenced by + // tools when the remote_mcp_ui_apps feature flag is enabled for the + // request (the inventory strips the _meta.ui block otherwise via + // stripMCPAppsMetadata), so registering them unconditionally is safe. + // Registering here — rather than in the stdio bootstrap — ensures the + // remote/HTTP server also serves them, fixing the "-32002 Resource not + // found" error clients hit after the tool returns a ui:// URI. + if UIAssetsAvailable() { + RegisterUIResources(ghServer) } return ghServer, nil } -// registerDynamicTools adds the dynamic toolset enable/disable tools to the server. -func registerDynamicTools(server *mcp.Server, inventory *inventory.Inventory, deps ToolDependencies, t translations.TranslationHelperFunc) { - dynamicDeps := DynamicToolDependencies{ - Server: server, - Inventory: inventory, - ToolDeps: deps, - T: t, - } - for _, tool := range DynamicTools(inventory) { - tool.RegisterFunc(server, dynamicDeps) - } -} - // ResolvedEnabledToolsets determines which toolsets should be enabled based on config. // Returns nil for "use defaults", empty slice for "none", or explicit list. -func ResolvedEnabledToolsets(dynamicToolsets bool, enabledToolsets []string, enabledTools []string) []string { - // In dynamic mode, remove "all" and "default" since users enable toolsets on demand - if dynamicToolsets && enabledToolsets != nil { - enabledToolsets = RemoveToolset(enabledToolsets, string(ToolsetMetadataAll.ID)) - enabledToolsets = RemoveToolset(enabledToolsets, string(ToolsetMetadataDefault.ID)) - } - +func ResolvedEnabledToolsets(enabledToolsets []string, enabledTools []string) []string { if enabledToolsets != nil { return enabledToolsets } - if dynamicToolsets { - // Dynamic mode with no toolsets specified: start empty so users enable on demand - return []string{} - } if len(enabledTools) > 0 { // When specific tools are requested but no toolsets, don't use default toolsets // This matches the original behavior: --tools=X alone registers only X @@ -204,6 +170,9 @@ func NewServer(version, name, title string, opts *mcp.ServerOptions) *mcp.Server func CompletionsHandler(getClient GetClientFn) func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) { return func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) { + if req == nil || req.Params == nil || req.Params.Ref == nil { + return nil, fmt.Errorf("missing required parameter: ref") + } switch req.Params.Ref.Type { case "ref/resource": if strings.HasPrefix(req.Params.Ref.URI, "repo://") { diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 325900732f..7f909f431c 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -5,14 +5,18 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "net/http" + "strings" "testing" "time" "github.com/github/github-mcp-server/pkg/lockdown" + "github.com/github/github-mcp-server/pkg/observability" + "github.com/github/github-mcp-server/pkg/observability/metrics" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -30,6 +34,7 @@ type stubDeps struct { t translations.TranslationHelperFunc flags FeatureFlags contentWindowSize int + obsv observability.Exporters } func (s stubDeps) GetClient(ctx context.Context) (*gogithub.Client, error) { @@ -60,11 +65,25 @@ func (s stubDeps) GetT() translations.TranslationHelperFunc { return s. func (s stubDeps) GetFlags(_ context.Context) FeatureFlags { return s.flags } func (s stubDeps) GetContentWindowSize() int { return s.contentWindowSize } func (s stubDeps) IsFeatureEnabled(_ context.Context, _ string) bool { return false } +func (s stubDeps) Logger(_ context.Context) *slog.Logger { + return s.obsv.Logger() +} +func (s stubDeps) Metrics(ctx context.Context) metrics.Metrics { + return s.obsv.Metrics(ctx) +} // Helper functions to create stub client functions for error testing -func stubClientFnFromHTTP(httpClient *http.Client) func(context.Context) (*gogithub.Client, error) { + +// stubExporters returns a discard-logger + noop-metrics Exporters for tests. +func stubExporters() observability.Exporters { + obs, _ := observability.NewExporters(slog.New(slog.DiscardHandler), metrics.NewNoopMetrics()) + return obs +} + +func stubClientFnFromHTTP(t *testing.T, httpClient *http.Client) func(context.Context) (*gogithub.Client, error) { + t.Helper() return func(_ context.Context) (*gogithub.Client, error) { - return gogithub.NewClient(httpClient), nil + return mustNewGHClient(t, httpClient), nil } } @@ -80,15 +99,37 @@ func stubGQLClientFnErr(errMsg string) func(context.Context) (*githubv4.Client, } } -func stubRepoAccessCache(client *githubv4.Client, ttl time.Duration) *lockdown.RepoAccessCache { +func stubRepoAccessCache(restClient *gogithub.Client, ttl time.Duration) *lockdown.RepoAccessCache { cacheName := fmt.Sprintf("repo-access-cache-test-%d", time.Now().UnixNano()) - return lockdown.GetInstance(client, lockdown.WithTTL(ttl), lockdown.WithCacheName(cacheName)) + return lockdown.NewRepoAccessCache( + githubv4.NewClient(newRepoAccessHTTPClient()), + restClient, + lockdown.WithTTL(ttl), + lockdown.WithCacheName(cacheName), + ) +} + +func mockRESTPermissionServer(t *testing.T, defaultPerm string, overrides map[string]string) *gogithub.Client { + t.Helper() + return mustNewGHClient(t, MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) { + perm := defaultPerm + for user, p := range overrides { + if strings.Contains(r.URL.Path, "/collaborators/"+user+"/") { + perm = p + break + } + } + resp := gogithub.RepositoryPermissionLevel{ + Permission: gogithub.Ptr(perm), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) } func stubFeatureFlags(enabledFlags map[string]bool) FeatureFlags { return FeatureFlags{ LockdownMode: enabledFlags["lockdown-mode"], - InsidersMode: enabledFlags["insiders-mode"], } } @@ -122,10 +163,9 @@ func TestNewMCPServer_CreatesSuccessfully(t *testing.T) { Translator: translations.NullTranslationHelper, ContentWindowSize: 5000, LockdownMode: false, - InsidersMode: false, } - deps := stubDeps{} + deps := stubDeps{obsv: stubExporters()} // Build inventory inv, err := NewInventory(cfg.Translator). @@ -248,28 +288,17 @@ func TestResolveEnabledToolsets(t *testing.T) { expectedResult []string }{ { - name: "nil toolsets without dynamic mode and no tools - use defaults", + name: "nil toolsets and no tools - use defaults", cfg: MCPServerConfig{ EnabledToolsets: nil, - DynamicToolsets: false, EnabledTools: nil, }, expectedResult: nil, // nil means "use defaults" }, - { - name: "nil toolsets with dynamic mode - start empty", - cfg: MCPServerConfig{ - EnabledToolsets: nil, - DynamicToolsets: true, - EnabledTools: nil, - }, - expectedResult: []string{}, // empty slice means no toolsets - }, { name: "explicit toolsets", cfg: MCPServerConfig{ EnabledToolsets: []string{"repos", "issues"}, - DynamicToolsets: false, }, expectedResult: []string{"repos", "issues"}, }, @@ -277,33 +306,47 @@ func TestResolveEnabledToolsets(t *testing.T) { name: "empty toolsets - disable all", cfg: MCPServerConfig{ EnabledToolsets: []string{}, - DynamicToolsets: false, }, - expectedResult: []string{}, // empty slice means no toolsets + expectedResult: []string{}, }, { name: "specific tools without toolsets - no default toolsets", cfg: MCPServerConfig{ EnabledToolsets: nil, - DynamicToolsets: false, EnabledTools: []string{"get_me"}, }, expectedResult: []string{}, // empty slice when tools specified but no toolsets }, - { - name: "dynamic mode with explicit toolsets removes all and default", - cfg: MCPServerConfig{ - EnabledToolsets: []string{"all", "repos"}, - DynamicToolsets: true, - }, - expectedResult: []string{"repos"}, // "all" is removed in dynamic mode - }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result := ResolvedEnabledToolsets(tc.cfg.DynamicToolsets, tc.cfg.EnabledToolsets, tc.cfg.EnabledTools) + result := ResolvedEnabledToolsets(tc.cfg.EnabledToolsets, tc.cfg.EnabledTools) assert.Equal(t, tc.expectedResult, result) }) } } + +func TestCompletionsHandler_RejectsMissingRef(t *testing.T) { + getClient := func(_ context.Context) (*gogithub.Client, error) { + return &gogithub.Client{}, nil + } + handler := CompletionsHandler(getClient) + + tests := []struct { + name string + req *mcp.CompleteRequest + }{ + {name: "nil request", req: nil}, + {name: "nil params", req: &mcp.CompleteRequest{}}, + {name: "nil ref", req: &mcp.CompleteRequest{Params: &mcp.CompleteParams{}}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := handler(context.Background(), tc.req) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "missing required parameter: ref") + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 3f1c291a7d..d1d585b3fa 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -7,7 +7,7 @@ import ( "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" ) @@ -123,11 +123,6 @@ var ( Description: "GitHub Stargazers related tools", Icon: "star", } - ToolsetMetadataDynamic = inventory.ToolsetMetadata{ - ID: "dynamic", - Description: "Discover GitHub MCP tools that can help achieve tasks by enabling additional sets of tools, you can control the enablement of any toolset to access its tools when this toolset is enabled.", - Icon: "tools", - } ToolsetLabels = inventory.ToolsetMetadata{ ID: "labels", Description: "GitHub Labels related tools", @@ -141,6 +136,20 @@ var ( Icon: "copilot", } + // Feature flag names for granular tool variants. + // When active, consolidated tools are replaced by single-purpose granular tools. + FeatureFlagIssuesGranular = "issues_granular" + FeatureFlagPullRequestsGranular = "pull_requests_granular" +) + +// HeaderAllowedFeatureFlags returns the feature flags that clients may enable via +// the X-MCP-Features header. It delegates to AllowedFeatureFlags as the single +// source of truth. +func HeaderAllowedFeatureFlags() []string { + return slices.Clone(AllowedFeatureFlags) +} + +var ( // Remote-only toolsets - these are only available in the remote MCP server // but are documented here for consistency and to enable automated documentation. ToolsetMetadataCopilotSpaces = inventory.ToolsetMetadata{ @@ -158,7 +167,7 @@ var ( // AllTools returns all tools with their embedded toolset metadata. // Tool functions return ServerTool directly with toolset info. func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { - return []inventory.ServerTool{ + return withCSVOutput([]inventory.ServerTool{ // Context tools GetMe(t), GetTeams(t), @@ -169,6 +178,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GetFileContents(t), ListCommits(t), SearchCode(t), + SearchCommits(t), GetCommit(t), ListBranches(t), ListTags(t), @@ -185,6 +195,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ListStarredRepositories(t), StarRepository(t), UnstarRepository(t), + ListRepositoryCollaborators(t), // Git tools GetRepositoryTree(t), @@ -193,8 +204,11 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { IssueRead(t), SearchIssues(t), ListIssues(t), + LegacyListIssues(t), ListIssueTypes(t), + ListIssueFields(t), IssueWrite(t), + LegacyIssueWrite(t), AddIssueComment(t), SubIssueWrite(t), @@ -244,6 +258,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ListDiscussions(t), GetDiscussion(t), GetDiscussionComments(t), + DiscussionCommentWrite(t), ListDiscussionCategories(t), // Actions tools @@ -274,7 +289,34 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GetLabelForLabelsToolset(t), ListLabels(t), LabelWrite(t), - } + + // Granular issue tools (feature-flagged, replace consolidated issue_write/sub_issue_write) + GranularCreateIssue(t), + GranularUpdateIssueTitle(t), + GranularUpdateIssueBody(t), + GranularUpdateIssueAssignees(t), + GranularUpdateIssueLabels(t), + GranularUpdateIssueMilestone(t), + GranularUpdateIssueType(t), + GranularUpdateIssueState(t), + GranularAddSubIssue(t), + GranularRemoveSubIssue(t), + GranularReprioritizeSubIssue(t), + GranularSetIssueFields(t), + + // Granular pull request tools (feature-flagged, replace consolidated update_pull_request/pull_request_review_write) + GranularUpdatePullRequestTitle(t), + GranularUpdatePullRequestBody(t), + GranularUpdatePullRequestState(t), + GranularUpdatePullRequestDraftState(t), + GranularRequestPullRequestReviewers(t), + GranularCreatePullRequestReview(t), + GranularSubmitPendingPullRequestReview(t), + GranularDeletePendingPullRequestReview(t), + GranularAddPullRequestReviewComment(t), + GranularResolveReviewThread(t), + GranularUnresolveReviewThread(t), + }) } // ToBoolPtr converts a bool to a *bool pointer. @@ -307,8 +349,8 @@ func GenerateToolsetsHelp() string { defaultBuf.WriteString(string(id)) } - // Get all available toolsets (excludes context and dynamic for display) - allToolsets := r.AvailableToolsets("context", "dynamic") + // Get all available toolsets (excludes context for display) + allToolsets := r.AvailableToolsets("context") var availableBuf strings.Builder const maxLineLength = 70 currentLine := "" diff --git a/pkg/github/tools_static_validation_test.go b/pkg/github/tools_static_validation_test.go new file mode 100644 index 0000000000..34cd309d6a --- /dev/null +++ b/pkg/github/tools_static_validation_test.go @@ -0,0 +1,36 @@ +package github + +import ( + "os" + "testing" + + "github.com/github/github-mcp-server/pkg/toolvalidation" + "github.com/stretchr/testify/require" +) + +// TestAllToolRegistrationsExplicitlySetReadOnlyHint statically scans every +// non-test Go source file in this package and asserts that every mcp.Tool +// composite literal explicitly sets Annotations.ReadOnlyHint. +// +// The AST scan itself lives in pkg/toolvalidation so downstream packages +// (e.g. github/github-mcp-server-remote) can apply the same guardrail to +// their own tool registrations without duplicating the parser logic. +// +// This complements TestAllToolsHaveRequiredMetadata, which can only check +// that Annotations is non-nil at runtime: Go cannot distinguish an unset +// bool field from one explicitly set to false. Source-level validation +// closes that gap and prevents future tool registrations from silently +// defaulting ReadOnlyHint to false (which has caused downstream agents to +// prompt for human approval on read-intent tools). +// +// Related issue: github/github-mcp-server#2483 +func TestAllToolRegistrationsExplicitlySetReadOnlyHint(t *testing.T) { + pkgDir, err := os.Getwd() + require.NoError(t, err, "must be able to resolve package directory") + + violations, err := toolvalidation.ScanReadOnlyHint(pkgDir) + require.NoError(t, err) + if len(violations) > 0 { + t.Fatal(toolvalidation.FormatReadOnlyHintViolations(violations)) + } +} diff --git a/pkg/github/tools_validation_test.go b/pkg/github/tools_validation_test.go index 90e3c744cb..1db85b2fc1 100644 --- a/pkg/github/tools_validation_test.go +++ b/pkg/github/tools_validation_test.go @@ -1,6 +1,11 @@ package github import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "strings" "testing" "github.com/github/github-mcp-server/pkg/inventory" @@ -111,7 +116,7 @@ func TestNoDuplicateToolNames(t *testing.T) { // First pass: identify tools that have feature flags (mutually exclusive at runtime) for _, tool := range tools { - if tool.FeatureFlagEnable != "" || tool.FeatureFlagDisable != "" { + if tool.FeatureFlagEnable != "" || len(tool.FeatureFlagDisable) > 0 { featureFlagged[tool.Tool.Name] = true } } @@ -184,3 +189,29 @@ func TestToolsetMetadataConsistency(t *testing.T) { } } } + +func TestGitHubPackageDoesNotReadInsidersMode(t *testing.T) { + files, err := filepath.Glob("*.go") + require.NoError(t, err) + + for _, file := range files { + if strings.HasSuffix(file, "_test.go") { + continue + } + + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, file, nil, 0) + require.NoError(t, err, "failed to parse %s", file) + + ast.Inspect(node, func(n ast.Node) bool { + selector, ok := n.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "InsidersMode" { + return true + } + + position := fset.Position(selector.Sel.Pos()) + t.Errorf("%s reads InsidersMode directly; gate behavior on concrete feature flags instead", position) + return true + }) + } +} diff --git a/pkg/github/toolset_instructions.go b/pkg/github/toolset_instructions.go index bc9da4e65c..ba6659612a 100644 --- a/pkg/github/toolset_instructions.go +++ b/pkg/github/toolset_instructions.go @@ -39,6 +39,10 @@ func generateProjectsToolsetInstructions(_ *inventory.Inventory) string { Workflow: 1) list_project_fields (get field IDs), 2) list_project_items (with pagination), 3) optional updates. +Project lifecycle: Use create_project to create a new ProjectsV2 for a user or organization (requires owner_type and title). Returns the new project's id, number, title, and url; pass the returned number as project_number to subsequent project tools. + +Iteration fields: Use create_iteration_field to add a new ITERATION field (e.g. "Sprint") to an existing project. Required: field_name, iteration_duration (days), start_date (YYYY-MM-DD). Only pass the iterations array when iterations need varying durations, breaks between them, or specific titles; otherwise omit it and GitHub creates three default iterations of iteration_duration days starting on start_date. + Status updates: Use list_project_status_updates to read recent project status updates (newest first). Use get_project_status_update with a node ID to get a single update. Use create_project_status_update to create a new status update for a project. Field usage: diff --git a/pkg/github/ui_embed.go b/pkg/github/ui_embed.go index 257856e156..c3f1cef9d2 100644 --- a/pkg/github/ui_embed.go +++ b/pkg/github/ui_embed.go @@ -34,7 +34,7 @@ func MustGetUIAsset(name string) string { // UIAssetsAvailable returns true if the MCP App UI assets have been built. // This checks for a known UI asset file to determine if `script/build-ui` has been run. // Use this to gracefully skip UI registration when assets aren't available, -// allowing Insiders mode to work for non-UI features without requiring a UI build. +// allowing non-UI features to work without requiring a UI build. func UIAssetsAvailable() bool { _, err := GetUIAsset("get-me.html") return err == nil diff --git a/pkg/github/ui_resources.go b/pkg/github/ui_resources.go index c41d2ac3f1..ab3ebfd163 100644 --- a/pkg/github/ui_resources.go +++ b/pkg/github/ui_resources.go @@ -10,6 +10,9 @@ import ( // These are static resources (not templates) that serve HTML content for // MCP App-enabled tools. The HTML is built from React/Primer components // in the ui/ directory using `script/build-ui`. +// +// Resource metadata follows the stable 2026-01-26 MCP Apps spec: +// https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx func RegisterUIResources(s *mcp.Server) { // Register the get_me UI resource s.AddResource( @@ -27,14 +30,14 @@ func RegisterUIResources(s *mcp.Server) { URI: GetMeUIResourceURI, MIMEType: MCPAppMIMEType, Text: html, - // MCP Apps UI metadata - CSP configuration to allow loading GitHub avatars - // See: https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx Meta: mcp.Meta{ "ui": map[string]any{ + // Allow loading images from GitHub's avatar CDN. "csp": map[string]any{ - // Allow loading images from GitHub's avatar CDN "resourceDomains": []string{"https://avatars.githubusercontent.com"}, }, + // Profile card renders inline within chat without a host border. + "prefersBorder": false, }, }, }, @@ -59,6 +62,14 @@ func RegisterUIResources(s *mcp.Server) { URI: IssueWriteUIResourceURI, MIMEType: MCPAppMIMEType, Text: html, + Meta: mcp.Meta{ + "ui": map[string]any{ + // No external origins required; documents the secure default. + "csp": map[string]any{}, + // Form surface benefits from a host-provided border. + "prefersBorder": true, + }, + }, }, }, }, nil @@ -81,6 +92,12 @@ func RegisterUIResources(s *mcp.Server) { URI: PullRequestWriteUIResourceURI, MIMEType: MCPAppMIMEType, Text: html, + Meta: mcp.Meta{ + "ui": map[string]any{ + "csp": map[string]any{}, + "prefersBorder": true, + }, + }, }, }, }, nil diff --git a/pkg/github/ui_resources_test.go b/pkg/github/ui_resources_test.go new file mode 100644 index 0000000000..928950ac73 --- /dev/null +++ b/pkg/github/ui_resources_test.go @@ -0,0 +1,123 @@ +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" +) + +// TestRegisterUIResources_ReadableViaClient verifies that each UI resource URI +// advertised by an MCP App-enabled tool (e.g. issue_write, create_pull_request, +// get_me) actually resolves to a registered resource on the server. +// +// Regression test for the "Error loading MCP App: MPC -32002: Resource not +// found" bug reported in issue #2467, where the HTTP/remote server returned a +// resource URI in the tool's _meta.ui block but never registered the matching +// resource — so the follow-up resources/read call from the client failed. +func TestRegisterUIResources_ReadableViaClient(t *testing.T) { + t.Parallel() + + if !UIAssetsAvailable() { + t.Skip("UI assets not built; run script/build-ui to enable this test") + } + + srv := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil) + RegisterUIResources(srv) + + // Connect an in-memory client/server pair and read each advertised URI. + st, ct := mcp.NewInMemoryTransports() + + type clientResult struct { + session *mcp.ClientSession + err error + } + clientCh := make(chan clientResult, 1) + go func() { + client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil) + cs, err := client.Connect(context.Background(), ct, nil) + clientCh <- clientResult{session: cs, err: err} + }() + + ss, err := srv.Connect(context.Background(), st, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = ss.Close() }) + + got := <-clientCh + require.NoError(t, got.err) + t.Cleanup(func() { _ = got.session.Close() }) + + uris := []string{ + GetMeUIResourceURI, + IssueWriteUIResourceURI, + PullRequestWriteUIResourceURI, + } + for _, uri := range uris { + t.Run(uri, func(t *testing.T) { + res, err := got.session.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: uri}) + require.NoError(t, err, "resource %s should be registered (got -32002 means it isn't)", uri) + require.NotNil(t, res) + require.NotEmpty(t, res.Contents) + assert.Equal(t, uri, res.Contents[0].URI) + assert.Equal(t, MCPAppMIMEType, res.Contents[0].MIMEType) + assert.NotEmpty(t, res.Contents[0].Text, "UI resource should return HTML body") + }) + } +} + +// TestNewMCPServer_RegistersUIResources verifies that NewMCPServer — the +// shared constructor used by both the stdio and HTTP entry points — registers +// the UI resources when UI assets are embedded. Previously this registration +// only happened in the stdio bootstrap, so remote/HTTP clients hit -32002. +func TestNewMCPServer_RegistersUIResources(t *testing.T) { + t.Parallel() + + if !UIAssetsAvailable() { + t.Skip("UI assets not built; run script/build-ui to enable this test") + } + + srv, err := NewMCPServer(context.Background(), &MCPServerConfig{ + Version: "test", + Translator: stubTranslator, + }, stubDeps{t: stubTranslator}, mustEmptyInventory(t)) + require.NoError(t, err) + + st, ct := mcp.NewInMemoryTransports() + + type clientResult struct { + session *mcp.ClientSession + err error + } + clientCh := make(chan clientResult, 1) + go func() { + client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil) + cs, err := client.Connect(context.Background(), ct, nil) + clientCh <- clientResult{session: cs, err: err} + }() + + ss, err := srv.Connect(context.Background(), st, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = ss.Close() }) + + got := <-clientCh + require.NoError(t, got.err) + t.Cleanup(func() { _ = got.session.Close() }) + + res, err := got.session.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: IssueWriteUIResourceURI}) + require.NoError(t, err) + require.NotNil(t, res) + require.NotEmpty(t, res.Contents) + assert.Equal(t, MCPAppMIMEType, res.Contents[0].MIMEType) +} + +// mustEmptyInventory builds an empty inventory for tests that only care about +// resources/prompts registered outside the inventory (such as the UI resources). +func mustEmptyInventory(t *testing.T) *inventory.Inventory { + t.Helper() + inv, err := NewInventory(stubTranslator).WithToolsets([]string{}).Build() + require.NoError(t, err) + return inv +} diff --git a/pkg/http/handler.go b/pkg/http/handler.go index 2e828211d1..eca628a47b 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -223,6 +223,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Cross-origin protection is intentionally left unset: this server + // authenticates via bearer tokens (not cookies), so Sec-Fetch-Site CSRF + // checks are unnecessary and would block browser-based MCP clients. As of + // go-sdk v1.6.0 a nil CrossOriginProtection disables the check by default; + // see also PR #2359. mcpHandler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { return ghServer }, &mcp.StreamableHTTPOptions{ @@ -236,13 +241,51 @@ func DefaultGitHubMCPServerFactory(r *http.Request, deps github.ToolDependencies return github.NewMCPServer(r.Context(), cfg, deps, inventory) } -// DefaultInventoryFactory creates the default inventory factory for HTTP mode -func DefaultInventoryFactory(_ *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker, scopeFetcher scopes.FetcherInterface) InventoryFactoryFunc { +// DefaultInventoryFactory creates the default inventory factory for HTTP mode. +// When the ServerConfig includes static flags (--toolsets, --read-only, etc.), +// a static inventory is built once at factory creation to pre-filter the tool +// universe. Per-request headers can only narrow within these bounds. +func DefaultInventoryFactory(cfg *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker, scopeFetcher scopes.FetcherInterface) InventoryFactoryFunc { + // Build the static tool/resource/prompt universe from CLI flags. + // This is done once at startup and captured in the closure. + staticTools, staticResources, staticPrompts := buildStaticInventory(cfg, t) + hasStaticFilters := hasStaticConfig(cfg) + + // Pre-compute valid tool names for filtering per-request tool headers. + // When a request asks for a tool by name that's been excluded from the + // static universe, we silently drop it rather than returning an error. + validToolNames := make(map[string]bool, len(staticTools)) + for i := range staticTools { + validToolNames[staticTools[i].Tool.Name] = true + } + return func(r *http.Request) (*inventory.Inventory, error) { - b := github.NewInventory(t). + b := inventory.NewBuilder(). + SetTools(staticTools). + SetResources(staticResources). + SetPrompts(staticPrompts). WithDeprecatedAliases(github.DeprecatedToolAliases). WithFeatureChecker(featureChecker) + // When static flags constrain the universe, default to showing + // everything within those bounds (per-request filters narrow further). + // When no static flags are set, preserve existing behavior where + // the default toolsets apply. + if hasStaticFilters { + b = b.WithToolsets([]string{"all"}) + } + + // Static read-only is an upper bound — enforce before request filters + if cfg.ReadOnly { + b = b.WithReadOnly(true) + } + + // Filter request tool names to only those in the static universe, + // so requests for statically-excluded tools degrade gracefully. + if hasStaticFilters { + r = filterRequestTools(r, validToolNames) + } + b = InventoryFiltersForRequest(r, b) b = PATScopeFilter(b, r, scopeFetcher) @@ -252,8 +295,74 @@ func DefaultInventoryFactory(_ *ServerConfig, t translations.TranslationHelperFu } } +// filterRequestTools returns a shallow copy of the request with any per-request +// tool names (from X-MCP-Tools header) filtered to only include tools that exist +// in validNames. This ensures requests for statically-excluded tools are silently +// ignored rather than causing build errors. +func filterRequestTools(r *http.Request, validNames map[string]bool) *http.Request { + reqTools := ghcontext.GetTools(r.Context()) + if len(reqTools) == 0 { + return r + } + + filtered := make([]string, 0, len(reqTools)) + for _, name := range reqTools { + if validNames[name] { + filtered = append(filtered, name) + } + } + ctx := ghcontext.WithTools(r.Context(), filtered) + return r.WithContext(ctx) +} + +// hasStaticConfig returns true if any static filtering flags are set on the ServerConfig. +func hasStaticConfig(cfg *ServerConfig) bool { + return cfg.ReadOnly || + cfg.EnabledToolsets != nil || + cfg.EnabledTools != nil || + len(cfg.ExcludeTools) > 0 +} + +// buildStaticInventory pre-filters the full tool/resource/prompt universe using +// the static config (toolsets, read-only, --tools, --exclude-tools). It does +// NOT install a feature checker: HTTP feature flags can come from per-request +// context (/insiders, X-MCP-Features), so dual-name feature variants — for +// example the granular issues/PRs tools that share a name with their +// non-granular siblings — must be carried through to the per-request +// inventory, which then installs a checker and resolves the flag before +// registering tools with the MCP server. +func buildStaticInventory(cfg *ServerConfig, t translations.TranslationHelperFunc) ([]inventory.ServerTool, []inventory.ServerResourceTemplate, []inventory.ServerPrompt) { + if !hasStaticConfig(cfg) { + return github.AllTools(t), github.AllResources(t), github.AllPrompts(t) + } + + b := github.NewInventory(t). + WithReadOnly(cfg.ReadOnly). + WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)) + + if len(cfg.EnabledTools) > 0 { + b = b.WithTools(github.CleanTools(cfg.EnabledTools)) + } + + if len(cfg.ExcludeTools) > 0 { + b = b.WithExcludeTools(cfg.ExcludeTools) + } + + inv, err := b.Build() + if err != nil { + // Fall back to all tools if there's an error (e.g. unknown tool names). + // The error will surface again at per-request time if relevant. + return github.AllTools(t), github.AllResources(t), github.AllPrompts(t) + } + + ctx := context.Background() + return inv.AvailableTools(ctx), inv.AvailableResourceTemplates(ctx), inv.AvailablePrompts(ctx) +} + // InventoryFiltersForRequest applies filters to the inventory builder -// based on the request context and headers +// based on the request context and headers. +// MCP Apps UI metadata is handled by the builder via the feature checker — +// no need to check headers here. func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *inventory.Builder { ctx := r.Context() @@ -265,7 +374,7 @@ func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *in tools := ghcontext.GetTools(ctx) if len(toolsets) > 0 { - builder = builder.WithToolsets(github.ResolvedEnabledToolsets(false, toolsets, tools)) // No dynamic toolsets in HTTP mode + builder = builder.WithToolsets(github.ResolvedEnabledToolsets(toolsets, tools)) } if len(tools) > 0 { diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go index 2a19e0a231..4f697ee0cb 100644 --- a/pkg/http/handler_test.go +++ b/pkg/http/handler_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "slices" "sort" + "strings" "testing" ghcontext "github.com/github/github-mcp-server/pkg/context" @@ -23,6 +24,10 @@ import ( ) func mockTool(name, toolsetID string, readOnly bool) inventory.ServerTool { + return mockToolFull(name, toolsetID, readOnly, false) +} + +func mockToolFull(name, toolsetID string, readOnly bool, isDefault bool) inventory.ServerTool { return inventory.ServerTool{ Tool: mcp.Tool{ Name: name, @@ -31,6 +36,7 @@ func mockTool(name, toolsetID string, readOnly bool) inventory.ServerTool { Toolset: inventory.ToolsetMetadata{ ID: inventory.ToolsetID(toolsetID), Description: "Test: " + toolsetID, + Default: isDefault, }, } } @@ -52,7 +58,9 @@ var _ scopes.FetcherInterface = allScopesFetcher{} func mockToolWithFeatureFlag(name, toolsetID string, readOnly bool, enableFlag, disableFlag string) inventory.ServerTool { tool := mockTool(name, toolsetID, readOnly) tool.FeatureFlagEnable = enableFlag - tool.FeatureFlagDisable = disableFlag + if disableFlag != "" { + tool.FeatureFlagDisable = []string{disableFlag} + } return tool } @@ -409,3 +417,531 @@ func TestHTTPHandlerRoutes(t *testing.T) { }) } } + +func TestStaticConfigEnforcement(t *testing.T) { + // Use default toolsets to match real-world behavior where repos/issues/pull_requests are defaults + tools := []inventory.ServerTool{ + mockToolFull("get_file_contents", "repos", true, true), + mockToolFull("create_repository", "repos", false, true), + mockToolFull("list_issues", "issues", true, true), + mockToolFull("create_issue", "issues", false, true), + mockToolFull("list_pull_requests", "pull_requests", true, true), + mockToolFull("create_pull_request", "pull_requests", false, true), + mockToolWithFeatureFlag("hidden_by_holdback", "repos", true, "", "mcp_holdback_consolidated_projects"), + } + + tests := []struct { + name string + config *ServerConfig + path string + headers map[string]string + expectedTools []string + }{ + { + name: "no static config preserves existing behavior", + config: &ServerConfig{Version: "test"}, + path: "/", + expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"}, + }, + { + name: "static read-only filters write tools", + config: &ServerConfig{Version: "test", ReadOnly: true}, + path: "/", + expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"}, + }, + { + name: "static read-only cannot be overridden by header", + config: &ServerConfig{Version: "test", ReadOnly: true}, + path: "/", + headers: map[string]string{ + headers.MCPReadOnlyHeader: "false", + }, + expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"}, + }, + { + name: "static toolsets restricts available tools", + config: &ServerConfig{Version: "test", EnabledToolsets: []string{"repos"}}, + path: "/", + expectedTools: []string{"get_file_contents", "create_repository", "hidden_by_holdback"}, + }, + { + name: "static toolsets cannot be expanded by header", + config: &ServerConfig{Version: "test", EnabledToolsets: []string{"repos"}}, + path: "/", + headers: map[string]string{ + headers.MCPToolsetsHeader: "issues", + }, + // Header asks for "issues" but only "repos" tools exist in the static universe + expectedTools: []string{}, + }, + { + name: "per-request header can narrow within static toolset bounds", + config: &ServerConfig{Version: "test", EnabledToolsets: []string{"repos", "issues"}}, + path: "/", + headers: map[string]string{ + headers.MCPToolsetsHeader: "repos", + }, + expectedTools: []string{"get_file_contents", "create_repository", "hidden_by_holdback"}, + }, + { + name: "static exclude-tools removes tools", + config: &ServerConfig{Version: "test", ExcludeTools: []string{"create_repository", "create_issue"}}, + path: "/", + expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "create_pull_request", "hidden_by_holdback"}, + }, + { + name: "static exclude-tools cannot be re-included by header", + config: &ServerConfig{Version: "test", ExcludeTools: []string{"create_repository"}}, + path: "/", + headers: map[string]string{ + headers.MCPToolsHeader: "create_repository,list_issues", + }, + // create_repository was excluded at static level, only list_issues available + expectedTools: []string{"list_issues"}, + }, + { + name: "static read-only combined with per-request toolset", + config: &ServerConfig{Version: "test", ReadOnly: true}, + path: "/", + headers: map[string]string{ + headers.MCPToolsetsHeader: "repos", + }, + expectedTools: []string{"get_file_contents", "hidden_by_holdback"}, + }, + { + name: "static toolset with URL readonly", + config: &ServerConfig{Version: "test", EnabledToolsets: []string{"repos", "issues"}}, + path: "/readonly", + expectedTools: []string{"get_file_contents", "list_issues", "hidden_by_holdback"}, + }, + { + name: "static tools enables specific tools only", + config: &ServerConfig{Version: "test", EnabledTools: []string{"list_issues", "get_file_contents"}}, + path: "/", + expectedTools: []string{"list_issues", "get_file_contents"}, + }, + { + name: "static tools cannot be expanded by header", + config: &ServerConfig{Version: "test", EnabledTools: []string{"list_issues"}}, + path: "/", + headers: map[string]string{ + headers.MCPToolsHeader: "create_repository", + }, + // create_repository isn't in the static universe so it's silently dropped; + // the empty filter shows all tools within static bounds + expectedTools: []string{"list_issues"}, + }, + { + name: "static exclude-tools combined with per-request exclude", + config: &ServerConfig{Version: "test", ExcludeTools: []string{"create_repository"}}, + path: "/", + headers: map[string]string{ + headers.MCPExcludeToolsHeader: "create_issue", + }, + // Both static and per-request exclusions apply + expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "create_pull_request", "hidden_by_holdback"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var capturedInventory *inventory.Inventory + var capturedCtx context.Context + + featureChecker := func(ctx context.Context, flag string) (bool, error) { + return slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag), nil + } + + apiHost, err := utils.NewAPIHost("https://api.github.com") + require.NoError(t, err) + + // Build static tools the same way the production code does + staticTools, staticResources, staticPrompts := buildStaticInventoryFromTools(tt.config, tools) + hasStatic := hasStaticConfig(tt.config) + + validToolNames := make(map[string]bool, len(staticTools)) + for _, tool := range staticTools { + validToolNames[tool.Tool.Name] = true + } + + inventoryFactory := func(r *http.Request) (*inventory.Inventory, error) { + capturedCtx = r.Context() + builder := inventory.NewBuilder(). + SetTools(staticTools). + SetResources(staticResources). + SetPrompts(staticPrompts). + WithDeprecatedAliases(github.DeprecatedToolAliases). + WithFeatureChecker(featureChecker) + + if hasStatic { + builder = builder.WithToolsets([]string{"all"}) + } + if tt.config.ReadOnly { + builder = builder.WithReadOnly(true) + } + + if hasStatic { + r = filterRequestTools(r, validToolNames) + } + + builder = InventoryFiltersForRequest(r, builder) + inv, buildErr := builder.Build() + if buildErr != nil { + return nil, buildErr + } + capturedInventory = inv + return inv, nil + } + + mcpServerFactory := func(_ *http.Request, _ github.ToolDependencies, _ *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) { + return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil + } + + handler := NewHTTPMcpHandler( + context.Background(), + tt.config, + nil, + translations.NullTranslationHelper, + slog.Default(), + apiHost, + WithInventoryFactory(inventoryFactory), + WithGitHubMCPServerFactory(mcpServerFactory), + WithScopeFetcher(allScopesFetcher{}), + ) + + r := chi.NewRouter() + handler.RegisterMiddleware(r) + handler.RegisterRoutes(r) + + req := httptest.NewRequest(http.MethodPost, tt.path, nil) + req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_testtoken") + for k, v := range tt.headers { + req.Header.Set(k, v) + } + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + require.NotNil(t, capturedInventory, "inventory should have been created") + + toolNames := extractToolNames(capturedCtx, capturedInventory) + expectedSorted := make([]string, len(tt.expectedTools)) + copy(expectedSorted, tt.expectedTools) + sort.Strings(expectedSorted) + + assert.Equal(t, expectedSorted, toolNames, "tools should match expected") + }) + } +} + +func TestStaticInventoryPreservesPerRequestFeatureVariants(t *testing.T) { + tools := []inventory.ServerTool{ + mockToolWithFeatureFlag("list_issues", "issues", true, "", github.FeatureFlagCSVOutput), + mockToolWithFeatureFlag("list_issues", "issues", true, github.FeatureFlagCSVOutput, ""), + } + cfg := &ServerConfig{Version: "test", EnabledToolsets: []string{"issues"}} + featureChecker := createHTTPFeatureChecker(nil, false) + + staticTools, _, _ := buildStaticInventoryFromTools(cfg, tools) + require.Len(t, staticTools, 2, "static upper bounds should preserve both feature variants") + + inv, err := inventory.NewBuilder(). + SetTools(staticTools). + WithFeatureChecker(featureChecker). + WithToolsets([]string{"all"}). + Build() + require.NoError(t, err) + + ctx := ghcontext.WithInsidersMode(context.Background(), true) + available := inv.AvailableTools(ctx) + require.Len(t, available, 1) + assert.Equal(t, "list_issues", available[0].Tool.Name) + assert.Equal(t, github.FeatureFlagCSVOutput, available[0].FeatureFlagEnable) +} + +// TestContentTypeHandling verifies that the MCP StreamableHTTP handler +// accepts Content-Type values with additional parameters like charset=utf-8. +// This is a regression test for https://github.com/github/github-mcp-server/issues/2333 +// where the Go SDK performs strict string matching against "application/json" +// and rejects requests with "application/json; charset=utf-8". +func TestContentTypeHandling(t *testing.T) { + tests := []struct { + name string + contentType string + expectUnsupportedMedia bool + }{ + { + name: "exact application/json is accepted", + contentType: "application/json", + expectUnsupportedMedia: false, + }, + { + name: "application/json with charset=utf-8 should be accepted", + contentType: "application/json; charset=utf-8", + expectUnsupportedMedia: false, + }, + { + name: "application/json with charset=UTF-8 should be accepted", + contentType: "application/json; charset=UTF-8", + expectUnsupportedMedia: false, + }, + { + name: "completely wrong content type is rejected", + contentType: "text/plain", + expectUnsupportedMedia: true, + }, + { + name: "empty content type is rejected", + contentType: "", + expectUnsupportedMedia: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a minimal MCP server factory + mcpServerFactory := func(_ *http.Request, _ github.ToolDependencies, _ *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) { + return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil + } + + // Create a simple inventory factory + inventoryFactory := func(_ *http.Request) (*inventory.Inventory, error) { + return inventory.NewBuilder(). + SetTools(testTools()). + WithToolsets([]string{"all"}). + Build() + } + + apiHost, err := utils.NewAPIHost("https://api.github.com") + require.NoError(t, err) + + handler := NewHTTPMcpHandler( + context.Background(), + &ServerConfig{Version: "test"}, + nil, + translations.NullTranslationHelper, + slog.Default(), + apiHost, + WithInventoryFactory(inventoryFactory), + WithGitHubMCPServerFactory(mcpServerFactory), + WithScopeFetcher(allScopesFetcher{}), + ) + + r := chi.NewRouter() + handler.RegisterMiddleware(r) + handler.RegisterRoutes(r) + + // Send an MCP initialize request as a POST with the given Content-Type + body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}` + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_testtoken") + req.Header.Set(headers.AcceptHeader, strings.Join([]string{headers.ContentTypeJSON, headers.ContentTypeEventStream}, ", ")) + if tt.contentType != "" { + req.Header.Set(headers.ContentTypeHeader, tt.contentType) + } + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if tt.expectUnsupportedMedia { + assert.Equal(t, http.StatusUnsupportedMediaType, rr.Code, + "expected 415 Unsupported Media Type for Content-Type: %q", tt.contentType) + } else { + assert.NotEqual(t, http.StatusUnsupportedMediaType, rr.Code, + "should not get 415 for Content-Type: %q, got status %d", tt.contentType, rr.Code) + } + }) + } +} + +// buildStaticInventoryFromTools is a test helper that mirrors buildStaticInventory +// but uses the provided mock tools instead of calling github.AllTools. +func buildStaticInventoryFromTools(cfg *ServerConfig, tools []inventory.ServerTool) ([]inventory.ServerTool, []inventory.ServerResourceTemplate, []inventory.ServerPrompt) { + if !hasStaticConfig(cfg) { + return tools, nil, nil + } + + b := inventory.NewBuilder(). + SetTools(tools). + WithReadOnly(cfg.ReadOnly). + WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)) + + if len(cfg.EnabledTools) > 0 { + b = b.WithTools(github.CleanTools(cfg.EnabledTools)) + } + + if len(cfg.ExcludeTools) > 0 { + b = b.WithExcludeTools(cfg.ExcludeTools) + } + + inv, err := b.Build() + if err != nil { + return tools, nil, nil + } + + ctx := context.Background() + return inv.AvailableTools(ctx), inv.AvailableResourceTemplates(ctx), inv.AvailablePrompts(ctx) +} + +func TestCrossOriginProtection(t *testing.T) { + jsonRPCBody := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}` + + apiHost, err := utils.NewAPIHost("https://api.githubcopilot.com") + require.NoError(t, err) + + handler := NewHTTPMcpHandler( + context.Background(), + &ServerConfig{ + Version: "test", + }, + nil, + translations.NullTranslationHelper, + slog.Default(), + apiHost, + WithInventoryFactory(func(_ *http.Request) (*inventory.Inventory, error) { + return inventory.NewBuilder().Build() + }), + WithGitHubMCPServerFactory(func(_ *http.Request, _ github.ToolDependencies, _ *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) { + return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil + }), + WithScopeFetcher(allScopesFetcher{}), + ) + + r := chi.NewRouter() + handler.RegisterMiddleware(r) + handler.RegisterRoutes(r) + + tests := []struct { + name string + secFetchSite string + origin string + }{ + { + name: "cross-site request with bearer token succeeds", + secFetchSite: "cross-site", + origin: "https://example.com", + }, + { + name: "same-origin request succeeds", + secFetchSite: "same-origin", + }, + { + name: "native client without Sec-Fetch-Site succeeds", + secFetchSite: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonRPCBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set(headers.AuthorizationHeader, "Bearer github_pat_xyz") + if tt.secFetchSite != "" { + req.Header.Set("Sec-Fetch-Site", tt.secFetchSite) + } + if tt.origin != "" { + req.Header.Set("Origin", tt.origin) + } + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code, "unexpected status code; body: %s", rr.Body.String()) + }) + } +} + +// TestInsidersRoutePreservesUIMeta is a regression test for the bug where +// _meta.ui was stripped from tools/list responses on the HTTP /insiders route. +// +// Before the fix: +// - buildStaticInventory called Build() on a builder configured with the +// HTTP feature checker (which reads insiders mode from the request ctx). +// - Build() invoked checkFeatureFlag(context.Background()) — bg ctx has no +// insiders mode, so the FF reported MCP Apps off, and stripMCPAppsMetadata +// ran eagerly against the static tool slice at server startup. +// - Per-request inventory factories then served pre-stripped tools regardless +// of whether the request actually came in via /insiders. +// +// After the fix: +// - Build() no longer touches MCP Apps metadata. +// - RegisterTools applies the strip per-request, using the request context +// where the HTTP feature checker correctly observes insiders mode. +func TestInsidersRoutePreservesUIMeta(t *testing.T) { + const uiURI = "ui://test/widget" + uiTool := mockTool("with_ui", "repos", true) + uiTool.Tool.Meta = mcp.Meta{"ui": map[string]any{"resourceUri": uiURI}} + + checker := createHTTPFeatureChecker(nil, false) + build := func() *inventory.Inventory { + inv, err := inventory.NewBuilder(). + SetTools([]inventory.ServerTool{uiTool}). + WithFeatureChecker(checker). + WithToolsets([]string{"all"}). + Build() + require.NoError(t, err) + return inv + } + + // Simulate a /insiders request: ctx has insiders mode set. + insidersCtx := ghcontext.WithInsidersMode(context.Background(), true) + + // AvailableTools no longer strips _meta.ui (post-fix), regardless of ctx. + // The strip lives in RegisterTools, gated on the per-request FF check. + insidersTools := build().AvailableTools(insidersCtx) + plainTools := build().AvailableTools(context.Background()) + + // On the /insiders path, the FF check returns true → no strip → _meta preserved. + enabled, _ := checker(insidersCtx, "remote_mcp_ui_apps") + require.True(t, enabled, "FF should be on for /insiders ctx") + require.Len(t, insidersTools, 1) + require.NotNil(t, insidersTools[0].Tool.Meta, "_meta should be present on /insiders") + require.Equal(t, uiURI, insidersTools[0].Tool.Meta["ui"].(map[string]any)["resourceUri"]) + + // On the non-insiders path, RegisterTools strips _meta.ui. + plainEnabled, _ := checker(context.Background(), "remote_mcp_ui_apps") + require.False(t, plainEnabled, "FF should be off for non-insiders ctx") + require.Len(t, plainTools, 1) +} + +// TestUIMetaStrippedWhenClientLacksCapability verifies that even on the +// /insiders path (where the feature flag is on), UI metadata is stripped from +// tools/list responses when the client did NOT advertise the +// io.modelcontextprotocol/ui extension capability. Per the 2026-01-26 MCP +// Apps spec, servers SHOULD check client capabilities before exposing +// UI-enabled tools. +func TestUIMetaStrippedWhenClientLacksCapability(t *testing.T) { + const uiURI = "ui://test/widget" + uiTool := mockTool("with_ui", "repos", true) + uiTool.Tool.Meta = mcp.Meta{"ui": map[string]any{"resourceUri": uiURI}} + + checker := createHTTPFeatureChecker(nil, false) + build := func() *inventory.Inventory { + inv, err := inventory.NewBuilder(). + SetTools([]inventory.ServerTool{uiTool}). + WithFeatureChecker(checker). + WithToolsets([]string{"all"}). + Build() + require.NoError(t, err) + return inv + } + + insidersCtx := ghcontext.WithInsidersMode(context.Background(), true) + withoutUICap := ghcontext.WithUISupport(insidersCtx, false) + withUICap := ghcontext.WithUISupport(insidersCtx, true) + + stripped := build().ToolsForRegistration(withoutUICap) + require.Len(t, stripped, 1) + require.Nil(t, stripped[0].Tool.Meta["ui"], "_meta.ui should be stripped when client lacks UI capability") + + preserved := build().ToolsForRegistration(withUICap) + require.Len(t, preserved, 1) + require.NotNil(t, preserved[0].Tool.Meta["ui"], "_meta.ui should be preserved when client advertises UI capability") + require.Equal(t, uiURI, preserved[0].Tool.Meta["ui"].(map[string]any)["resourceUri"]) + + // Unknown capability falls through to the FF gate (insiders ctx → kept). + unknown := build().ToolsForRegistration(insidersCtx) + require.Len(t, unknown, 1) + require.NotNil(t, unknown[0].Tool.Meta["ui"], "_meta.ui should be preserved when capability is unknown and FF is on") +} diff --git a/pkg/http/middleware/cors.go b/pkg/http/middleware/cors.go new file mode 100644 index 0000000000..2eaf4227b4 --- /dev/null +++ b/pkg/http/middleware/cors.go @@ -0,0 +1,43 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/github/github-mcp-server/pkg/http/headers" +) + +// SetCorsHeaders is middleware that sets CORS headers to allow browser-based +// MCP clients to connect from any origin. This is safe because the server +// authenticates via bearer tokens (not cookies), so cross-origin requests +// cannot exploit ambient credentials. +func SetCorsHeaders(h http.Handler) http.Handler { + allowHeaders := strings.Join([]string{ + "Content-Type", + "Mcp-Session-Id", + "Mcp-Protocol-Version", + "Last-Event-ID", + headers.AuthorizationHeader, + headers.MCPReadOnlyHeader, + headers.MCPToolsetsHeader, + headers.MCPToolsHeader, + headers.MCPExcludeToolsHeader, + headers.MCPFeaturesHeader, + headers.MCPLockdownHeader, + headers.MCPInsidersHeader, + }, ", ") + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + w.Header().Set("Access-Control-Max-Age", "86400") + w.Header().Set("Access-Control-Expose-Headers", "Mcp-Session-Id, WWW-Authenticate") + w.Header().Set("Access-Control-Allow-Headers", allowHeaders) + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + h.ServeHTTP(w, r) + }) +} diff --git a/pkg/http/middleware/cors_test.go b/pkg/http/middleware/cors_test.go new file mode 100644 index 0000000000..fbd7c40cf9 --- /dev/null +++ b/pkg/http/middleware/cors_test.go @@ -0,0 +1,45 @@ +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/github/github-mcp-server/pkg/http/middleware" + "github.com/stretchr/testify/assert" +) + +func TestSetCorsHeaders(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := middleware.SetCorsHeaders(inner) + + t.Run("OPTIONS preflight returns 200 with CORS headers", func(t *testing.T) { + req := httptest.NewRequest(http.MethodOptions, "/", nil) + req.Header.Set("Origin", "http://localhost:6274") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin")) + assert.Contains(t, rr.Header().Get("Access-Control-Allow-Methods"), "POST") + assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "Authorization") + assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "Content-Type") + assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "Mcp-Session-Id") + assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "X-MCP-Lockdown") + assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "X-MCP-Insiders") + assert.Contains(t, rr.Header().Get("Access-Control-Expose-Headers"), "Mcp-Session-Id") + assert.Contains(t, rr.Header().Get("Access-Control-Expose-Headers"), "WWW-Authenticate") + }) + + t.Run("POST request includes CORS headers", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("Origin", "http://localhost:6274") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin")) + }) +} diff --git a/pkg/http/oauth/oauth.go b/pkg/http/oauth/oauth.go index 3b4d41959f..ffa7669a9d 100644 --- a/pkg/http/oauth/oauth.go +++ b/pkg/http/oauth/oauth.go @@ -49,6 +49,15 @@ type Config struct { // This is used to restore the original path when a proxy strips a base path before forwarding. // If empty, requests are treated as already using the external path. ResourcePath string + + // TrustProxyHeaders indicates whether X-Forwarded-Host and X-Forwarded-Proto + // should be honored when deriving the effective host and scheme for OAuth + // resource URLs. This must only be enabled when the server is deployed + // behind a trusted proxy that sets these headers; otherwise an untrusted + // client can influence the OAuth resource metadata URL advertised to MCP + // clients. When BaseURL is set, it always takes precedence and these + // headers are unused. + TrustProxyHeaders bool } // AuthHandler handles OAuth-related HTTP endpoints. @@ -196,18 +205,31 @@ func (h *AuthHandler) buildResourceURL(r *http.Request, resourcePath string) str } // GetEffectiveHostAndScheme returns the effective host and scheme for a request. +// +// X-Forwarded-Host and X-Forwarded-Proto are only honored when cfg.TrustProxyHeaders +// is true. Without that opt-in, an untrusted client could otherwise influence the +// OAuth resource metadata URL advertised to MCP clients. func GetEffectiveHostAndScheme(r *http.Request, cfg *Config) (host, scheme string) { //nolint:revive - if fh := r.Header.Get(headers.ForwardedHostHeader); fh != "" { - host = fh - } else { + trustProxy := cfg != nil && cfg.TrustProxyHeaders + + if trustProxy { + if fh := r.Header.Get(headers.ForwardedHostHeader); fh != "" { + host = fh + } + } + if host == "" { host = r.Host } if host == "" { host = "localhost" } - if fp := r.Header.Get(headers.ForwardedProtoHeader); fp != "" { - scheme = strings.ToLower(fp) - } else { + + if trustProxy { + if fp := r.Header.Get(headers.ForwardedProtoHeader); fp != "" { + scheme = strings.ToLower(fp) + } + } + if scheme == "" { if r.TLS != nil { scheme = "https" } else { diff --git a/pkg/http/oauth/oauth_test.go b/pkg/http/oauth/oauth_test.go index 6d76b579f3..f39ef39b87 100644 --- a/pkg/http/oauth/oauth_test.go +++ b/pkg/http/oauth/oauth_test.go @@ -84,6 +84,19 @@ func TestGetEffectiveHostAndScheme(t *testing.T) { expectedHost: "example.com", expectedScheme: "http", // defaults to http }, + { + name: "X-Forwarded-Host ignored by default", + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Host = "internal.example.com" + req.Header.Set(headers.ForwardedHostHeader, "attacker.example.com") + req.Header.Set(headers.ForwardedProtoHeader, "https") + return req + }, + cfg: &Config{}, + expectedHost: "internal.example.com", + expectedScheme: "http", + }, { name: "request with X-Forwarded-Host header", setupRequest: func() *http.Request { @@ -92,7 +105,7 @@ func TestGetEffectiveHostAndScheme(t *testing.T) { req.Header.Set(headers.ForwardedHostHeader, "public.example.com") return req }, - cfg: &Config{}, + cfg: &Config{TrustProxyHeaders: true}, expectedHost: "public.example.com", expectedScheme: "http", }, @@ -104,7 +117,7 @@ func TestGetEffectiveHostAndScheme(t *testing.T) { req.Header.Set(headers.ForwardedProtoHeader, "http") return req }, - cfg: &Config{}, + cfg: &Config{TrustProxyHeaders: true}, expectedHost: "example.com", expectedScheme: "http", }, @@ -117,7 +130,7 @@ func TestGetEffectiveHostAndScheme(t *testing.T) { req.Header.Set(headers.ForwardedProtoHeader, "https") return req }, - cfg: &Config{}, + cfg: &Config{TrustProxyHeaders: true}, expectedHost: "public.example.com", expectedScheme: "https", }, @@ -142,7 +155,7 @@ func TestGetEffectiveHostAndScheme(t *testing.T) { req.Header.Set(headers.ForwardedProtoHeader, "http") return req }, - cfg: &Config{}, + cfg: &Config{TrustProxyHeaders: true}, expectedHost: "example.com", expectedScheme: "http", }, @@ -154,7 +167,7 @@ func TestGetEffectiveHostAndScheme(t *testing.T) { req.Header.Set(headers.ForwardedProtoHeader, "HTTPS") return req }, - cfg: &Config{}, + cfg: &Config{TrustProxyHeaders: true}, expectedHost: "example.com", expectedScheme: "https", }, @@ -301,8 +314,21 @@ func TestBuildResourceMetadataURL(t *testing.T) { expectedURL: "https://custom.example.com/.well-known/oauth-protected-resource/mcp", }, { - name: "with forwarded headers", + name: "with forwarded headers ignored by default", cfg: &Config{}, + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/mcp", nil) + req.Host = "internal.example.com" + req.Header.Set(headers.ForwardedHostHeader, "attacker.example.com") + req.Header.Set(headers.ForwardedProtoHeader, "https") + return req + }, + resourcePath: "/mcp", + expectedURL: "http://internal.example.com/.well-known/oauth-protected-resource/mcp", + }, + { + name: "with forwarded headers", + cfg: &Config{TrustProxyHeaders: true}, setupRequest: func() *http.Request { req := httptest.NewRequest(http.MethodGet, "/mcp", nil) req.Host = "internal.example.com" diff --git a/pkg/http/server.go b/pkg/http/server.go index 8723039408..3c9d7679e4 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -8,25 +8,23 @@ import ( "net/http" "os" "os/signal" - "slices" "syscall" "time" ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/http/middleware" "github.com/github/github-mcp-server/pkg/http/oauth" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/lockdown" + "github.com/github/github-mcp-server/pkg/observability" + "github.com/github/github-mcp-server/pkg/observability/metrics" "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-chi/chi/v5" ) -// knownFeatureFlags are the feature flags that can be enabled via X-MCP-Features header. -// Only these flags are accepted from headers. -var knownFeatureFlags = []string{} - type ServerConfig struct { // Version of the server Version string @@ -45,6 +43,13 @@ type ServerConfig struct { // This is used to restore the original path when a proxy strips a base path before forwarding. ResourcePath string + // TrustProxyHeaders indicates whether X-Forwarded-Host and X-Forwarded-Proto + // should be honored when constructing OAuth resource metadata URLs. Only + // enable this when the server is deployed behind a trusted proxy that sets + // these headers. When BaseURL is set, it always wins and this setting has + // no effect. + TrustProxyHeaders bool + // ExportTranslations indicates if we should export translations // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#i18n--overriding-descriptions ExportTranslations bool @@ -67,6 +72,28 @@ type ServerConfig struct { // ScopeChallenge indicates if we should return OAuth scope challenges, and if we should perform // tool filtering based on token scopes. ScopeChallenge bool + + // ReadOnly indicates if we should only register read-only tools. + // When set via CLI flag, this acts as an upper bound — per-request headers + // cannot re-enable write tools. + ReadOnly bool + + // EnabledToolsets is a list of toolsets to enable. + // When set via CLI flag, per-request headers can only narrow within these toolsets. + EnabledToolsets []string + + // EnabledTools is a list of specific tools to enable (additive to toolsets). + EnabledTools []string + + // ExcludeTools is a list of tool names to disable regardless of other settings. + // When set via CLI flag, per-request headers cannot re-include these tools. + ExcludeTools []string + + // EnabledFeatures is a list of feature flags that are enabled. + EnabledFeatures []string + + // InsidersMode expands to the curated set of feature flags enabled for insiders. + InsidersMode bool } func RunHTTPServer(cfg ServerConfig) error { @@ -90,7 +117,7 @@ func RunHTTPServer(cfg ServerConfig) error { slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo}) } logger := slog.New(slogHandler) - logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "lockdownEnabled", cfg.LockdownMode) + logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "lockdownEnabled", cfg.LockdownMode, "readOnly", cfg.ReadOnly, "insidersMode", cfg.InsidersMode) apiHost, err := utils.NewAPIHost(cfg.Host) if err != nil { @@ -104,7 +131,12 @@ func RunHTTPServer(cfg ServerConfig) error { repoAccessOpts = append(repoAccessOpts, lockdown.WithTTL(*cfg.RepoAccessCacheTTL)) } - featureChecker := createHTTPFeatureChecker() + featureChecker := createHTTPFeatureChecker(cfg.EnabledFeatures, cfg.InsidersMode) + + obs, err := observability.NewExporters(logger, metrics.NewNoopMetrics()) + if err != nil { + return fmt.Errorf("failed to create observability exporters: %w", err) + } deps := github.NewRequestDeps( apiHost, @@ -114,6 +146,7 @@ func RunHTTPServer(cfg ServerConfig) error { t, cfg.ContentWindowSize, featureChecker, + obs, ) // Initialize the global tool scope map @@ -124,8 +157,9 @@ func RunHTTPServer(cfg ServerConfig) error { // Register OAuth protected resource metadata endpoints oauthCfg := &oauth.Config{ - BaseURL: cfg.BaseURL, - ResourcePath: cfg.ResourcePath, + BaseURL: cfg.BaseURL, + ResourcePath: cfg.ResourcePath, + TrustProxyHeaders: cfg.TrustProxyHeaders, } serverOptions := []HandlerOption{} @@ -142,6 +176,8 @@ func RunHTTPServer(cfg ServerConfig) error { } r.Group(func(r chi.Router) { + r.Use(middleware.SetCorsHeaders) + // Register Middleware First, needs to be before route registration handler.RegisterMiddleware(r) @@ -203,19 +239,16 @@ func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error { return nil } -// createHTTPFeatureChecker creates a feature checker that reads header features from context -// and validates them against the knownFeatureFlags whitelist -func createHTTPFeatureChecker() inventory.FeatureFlagChecker { - // Pre-compute whitelist as set for O(1) lookup - knownSet := make(map[string]bool, len(knownFeatureFlags)) - for _, f := range knownFeatureFlags { - knownSet[f] = true - } - +// createHTTPFeatureChecker creates a feature checker that resolves static CLI +// features plus per-request header features and insiders mode. +func createHTTPFeatureChecker(enabledFeatures []string, insidersMode bool) inventory.FeatureFlagChecker { return func(ctx context.Context, flag string) (bool, error) { - if knownSet[flag] && slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag) { - return true, nil - } - return false, nil + headerFeatures := ghcontext.GetHeaderFeatures(ctx) + features := make([]string, 0, len(enabledFeatures)+len(headerFeatures)) + features = append(features, enabledFeatures...) + features = append(features, headerFeatures...) + + effective := github.ResolveFeatureFlags(features, insidersMode || ghcontext.IsInsidersMode(ctx)) + return effective[flag], nil } } diff --git a/pkg/http/server_test.go b/pkg/http/server_test.go new file mode 100644 index 0000000000..62511775a9 --- /dev/null +++ b/pkg/http/server_test.go @@ -0,0 +1,134 @@ +package http + +import ( + "context" + "testing" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateHTTPFeatureChecker(t *testing.T) { + tests := []struct { + name string + staticFeatures []string + staticInsiders bool + flagName string + headerFeatures []string + insidersMode bool + wantEnabled bool + }{ + { + name: "allowed issues_granular flag accepted from header", + flagName: github.FeatureFlagIssuesGranular, + headerFeatures: []string{github.FeatureFlagIssuesGranular}, + wantEnabled: true, + }, + { + name: "allowed pull_requests_granular flag accepted from header", + flagName: github.FeatureFlagPullRequestsGranular, + headerFeatures: []string{github.FeatureFlagPullRequestsGranular}, + wantEnabled: true, + }, + { + name: "MCP Apps flag accepted from header", + flagName: github.MCPAppsFeatureFlag, + headerFeatures: []string{github.MCPAppsFeatureFlag}, + wantEnabled: true, + }, + { + name: "unknown flag in header is ignored", + flagName: "unknown_flag", + headerFeatures: []string{"unknown_flag"}, + wantEnabled: false, + }, + { + name: "allowed flag not in header returns false", + flagName: github.FeatureFlagIssuesGranular, + headerFeatures: nil, + wantEnabled: false, + }, + { + name: "allowed flag with different flag in header returns false", + flagName: github.FeatureFlagIssuesGranular, + headerFeatures: []string{github.FeatureFlagPullRequestsGranular}, + wantEnabled: false, + }, + { + name: "multiple allowed flags in header", + flagName: github.FeatureFlagIssuesGranular, + headerFeatures: []string{github.FeatureFlagIssuesGranular, github.FeatureFlagPullRequestsGranular}, + wantEnabled: true, + }, + { + name: "empty header features", + flagName: github.FeatureFlagIssuesGranular, + headerFeatures: []string{}, + wantEnabled: false, + }, + { + name: "insiders mode enables MCP Apps without header", + flagName: github.MCPAppsFeatureFlag, + insidersMode: true, + wantEnabled: true, + }, + { + name: "static feature is enabled without header", + staticFeatures: []string{github.FeatureFlagCSVOutput}, + flagName: github.FeatureFlagCSVOutput, + wantEnabled: true, + }, + { + name: "static features combine with header features", + staticFeatures: []string{github.FeatureFlagCSVOutput}, + flagName: github.FeatureFlagIssuesGranular, + headerFeatures: []string{github.FeatureFlagIssuesGranular}, + wantEnabled: true, + }, + { + name: "static insiders enables insiders flags without route context", + staticInsiders: true, + flagName: github.FeatureFlagCSVOutput, + wantEnabled: true, + }, + { + name: "insiders mode enables internal-only insiders flags", + flagName: github.FeatureFlagIFCLabels, + insidersMode: true, + wantEnabled: true, + }, + { + name: "insiders mode does not enable granular flags", + flagName: github.FeatureFlagIssuesGranular, + insidersMode: true, + wantEnabled: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checker := createHTTPFeatureChecker(tt.staticFeatures, tt.staticInsiders) + ctx := context.Background() + if len(tt.headerFeatures) > 0 { + ctx = ghcontext.WithHeaderFeatures(ctx, tt.headerFeatures) + } + if tt.insidersMode { + ctx = ghcontext.WithInsidersMode(ctx, true) + } + + enabled, err := checker(ctx, tt.flagName) + require.NoError(t, err) + assert.Equal(t, tt.wantEnabled, enabled) + }) + } +} + +func TestHeaderAllowedFeatureFlagsMatchesAllowed(t *testing.T) { + // Ensure HeaderAllowedFeatureFlags delegates to AllowedFeatureFlags + allowed := github.HeaderAllowedFeatureFlags() + assert.Equal(t, github.AllowedFeatureFlags, allowed, + "HeaderAllowedFeatureFlags() should match AllowedFeatureFlags") + assert.NotEmpty(t, allowed, "AllowedFeatureFlags should not be empty") +} diff --git a/pkg/ifc/ifc.go b/pkg/ifc/ifc.go new file mode 100644 index 0000000000..e6eeb407bc --- /dev/null +++ b/pkg/ifc/ifc.go @@ -0,0 +1,108 @@ +// Package ifc provides Information Flow Control labels for annotating MCP tool outputs. +// The actual IFC enforcement engine lives in a separate service; this package only +// defines the label schema used for annotations. +package ifc + +type Integrity string + +const ( + IntegrityTrusted Integrity = "trusted" + IntegrityUntrusted Integrity = "untrusted" +) + +type Confidentiality string + +const ( + ConfidentialityPublic Confidentiality = "public" + ConfidentialityPrivate Confidentiality = "private" +) + +type SecurityLabel struct { + Integrity Integrity `json:"integrity"` + Confidentiality Confidentiality `json:"confidentiality"` +} + +// PublicTrusted returns a label for trusted, publicly readable data. +func PublicTrusted() SecurityLabel { + return SecurityLabel{ + Integrity: IntegrityTrusted, + Confidentiality: ConfidentialityPublic, + } +} + +// PublicUntrusted returns a label for untrusted, publicly readable data. +func PublicUntrusted() SecurityLabel { + return SecurityLabel{ + Integrity: IntegrityUntrusted, + Confidentiality: ConfidentialityPublic, + } +} + +// PrivateTrusted returns a label for trusted data restricted to the readers +// of the originating repository. The reader set is opaque on the wire (a +// single "private" marker); the client engine resolves the concrete readers +// from the GitHub API on demand at egress decision time. +func PrivateTrusted() SecurityLabel { + return SecurityLabel{ + Integrity: IntegrityTrusted, + Confidentiality: ConfidentialityPrivate, + } +} + +// PrivateUntrusted returns a label for untrusted data restricted to the +// readers of the originating repository. See PrivateTrusted for the reader +// resolution model. +func PrivateUntrusted() SecurityLabel { + return SecurityLabel{ + Integrity: IntegrityUntrusted, + Confidentiality: ConfidentialityPrivate, + } +} + +func LabelGetMe() SecurityLabel { + return PublicTrusted() +} + +// LabelListIssues returns the IFC label for a list_issues result. +// Public repositories are universally readable; private repositories are +// restricted to their collaborators (resolved client-side from the marker). +// Issue contents are attacker-controllable, so integrity is always untrusted. +func LabelListIssues(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateUntrusted() + } + return PublicUntrusted() +} + +// LabelGetFileContents returns the IFC label for a get_file_contents result. +// Public repository file contents may be authored by anyone via pull requests +// and are therefore untrusted. In private repositories only collaborators can +// land changes, so contents are treated as trusted. +func LabelGetFileContents(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateTrusted() + } + return PublicUntrusted() +} + +// LabelSearchIssues returns the IFC label for a multi-repository search +// result, joining per-repository labels across all matched repositories. +// Used by both search_issues and search_repositories. +// +// Integrity is always untrusted because results expose user-authored content. +// +// Confidentiality follows the IFC meet (greatest lower bound): if any matched +// repository is private the joined label is private; otherwise public. The +// reader set is opaque (the "private" marker); the client engine resolves +// concrete readers on demand at egress decision time. +// +// An empty result set is treated as public-untrusted (no repository data is +// leaked). +func LabelSearchIssues(repoVisibilities []bool) SecurityLabel { + for _, isPrivate := range repoVisibilities { + if isPrivate { + return PrivateUntrusted() + } + } + return PublicUntrusted() +} diff --git a/pkg/ifc/ifc_test.go b/pkg/ifc/ifc_test.go new file mode 100644 index 0000000000..669f5ff0cc --- /dev/null +++ b/pkg/ifc/ifc_test.go @@ -0,0 +1,51 @@ +package ifc + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLabelSearchIssues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + visibilities []bool + wantConfidential Confidentiality + }{ + { + name: "empty result is treated as public", + wantConfidential: ConfidentialityPublic, + }, + { + name: "single public repo", + visibilities: []bool{false}, + wantConfidential: ConfidentialityPublic, + }, + { + name: "all public repos stay public", + visibilities: []bool{false, false, false}, + wantConfidential: ConfidentialityPublic, + }, + { + name: "any private match flips to private", + visibilities: []bool{false, true, false}, + wantConfidential: ConfidentialityPrivate, + }, + { + name: "all private repos stay private", + visibilities: []bool{true, true}, + wantConfidential: ConfidentialityPrivate, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + label := LabelSearchIssues(tc.visibilities) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, tc.wantConfidential, label.Confidentiality) + }) + } +} diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index d492e69b5a..9ecaca1f57 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -14,6 +14,11 @@ var ( ErrUnknownTools = errors.New("unknown tools specified in WithTools") ) +// mcpAppsFeatureFlag is the feature flag name that controls MCP Apps UI metadata. +// This is defined here to avoid importing pkg/github (which imports pkg/inventory). +// The value must match github.MCPAppsFeatureFlag. +const mcpAppsFeatureFlag = "remote_mcp_ui_apps" + // ToolFilter is a function that determines if a tool should be included. // Returns true if the tool should be included, false to exclude it. type ToolFilter func(ctx context.Context, tool *ServerTool) (bool, error) @@ -48,7 +53,6 @@ type Builder struct { featureChecker FeatureFlagChecker filters []ToolFilter // filters to apply to all tools generateInstructions bool - insidersMode bool } // NewBuilder creates a new Builder. @@ -102,8 +106,7 @@ func (b *Builder) WithServerInstructions() *Builder { // - "default": expands to toolsets marked with Default: true in their metadata // // Input strings are trimmed of whitespace and duplicates are removed. -// Pass nil to use default toolsets. Pass an empty slice to disable all toolsets -// (useful for dynamic toolsets mode where tools are enabled on demand). +// Pass nil to use default toolsets. Pass an empty slice to disable all toolsets. // Returns self for chaining. func (b *Builder) WithToolsets(toolsetIDs []string) *Builder { b.toolsetIDs = toolsetIDs @@ -124,8 +127,20 @@ func (b *Builder) WithTools(toolNames []string) *Builder { // WithFeatureChecker sets the feature flag checker function. // The checker receives a context (for actor extraction) and feature flag name, -// returns (enabled, error). If error occurs, it will be logged and treated as false. -// If checker is nil, all feature flag checks return false. +// and returns (enabled, error). Errors are logged and treated as "not enabled". +// +// When the checker is non-nil, Build() installs a feature-flag ToolFilter +// at the head of the filter pipeline so that tools annotated with +// FeatureFlagEnable / FeatureFlagDisable are gated accordingly. Resources +// and prompts use the same checker via an explicit guard at their iteration +// site. +// +// When the checker is nil, no feature-flag filter is installed; tools, +// resources, and prompts pass through feature-flag gating unchanged. The +// per-request inventory in HTTP mode must always install a checker so that +// MCP registration (which can only serve a given tool name once) sees a +// deduplicated set of dual-name variants. +// // Returns self for chaining. func (b *Builder) WithFeatureChecker(checker FeatureFlagChecker) *Builder { b.featureChecker = checker @@ -154,15 +169,6 @@ func (b *Builder) WithExcludeTools(toolNames []string) *Builder { return b } -// WithInsidersMode enables or disables insiders mode features. -// When insiders mode is disabled (default), UI metadata is removed from tools -// so clients won't attempt to load UI resources. -// Returns self for chaining. -func (b *Builder) WithInsidersMode(enabled bool) *Builder { - b.insidersMode = enabled - return b -} - // CreateExcludeToolsFilter creates a ToolFilter that excludes tools by name. // Any tool whose name appears in the excluded list will be filtered out. // The input slice should already be cleaned (trimmed, deduplicated). @@ -204,10 +210,16 @@ func cleanTools(tools []string) []string { // (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) { - // When insiders mode is disabled, strip insiders-only features from tools tools := b.tools - if !b.insidersMode { - tools = stripInsidersFeatures(b.tools) + + // Install the feature-flag filter at the head of the pipeline so that + // flag-gated tools are excluded before any user-supplied WithFilter sees + // them. Doing this in Build() (rather than inside WithFeatureChecker) + // keeps the install idempotent — repeated WithFeatureChecker calls + // replace the checker without stacking duplicate filters. + filters := b.filters + if b.featureChecker != nil { + filters = append([]ToolFilter{createFeatureFlagFilter(b.featureChecker)}, filters...) } r := &Inventory{ @@ -217,7 +229,7 @@ func (b *Builder) Build() (*Inventory, error) { deprecatedAliases: b.deprecatedAliases, readOnly: b.readOnly, featureChecker: b.featureChecker, - filters: b.filters, + filters: filters, } // Process toolsets and pre-compute metadata in a single pass @@ -375,24 +387,17 @@ func (b *Builder) processToolsets() (map[ToolsetID]bool, []string, []ToolsetID, return enabledToolsets, unrecognized, allToolsetIDs, validIDs, defaultToolsetIDList, descriptions } -// insidersOnlyMetaKeys lists the Meta keys that are only available in insiders mode. -// Add new experimental feature keys here to have them automatically stripped -// when insiders mode is disabled. -var insidersOnlyMetaKeys = []string{ +// mcpAppsMetaKeys lists the Meta keys controlled by the remote_mcp_ui_apps feature flag. +var mcpAppsMetaKeys = []string{ "ui", // MCP Apps UI metadata } -// stripInsidersFeatures removes insiders-only features from tools. -// This includes removing tools marked with InsidersOnly and stripping -// Meta keys listed in insidersOnlyMetaKeys from remaining tools. -func stripInsidersFeatures(tools []ServerTool) []ServerTool { +// stripMCPAppsMetadata removes MCP Apps UI metadata from tools when the +// remote_mcp_ui_apps feature flag is not enabled. +func stripMCPAppsMetadata(tools []ServerTool) []ServerTool { result := make([]ServerTool, 0, len(tools)) for _, tool := range tools { - // Skip tools marked as insiders-only - if tool.InsidersOnly { - continue - } - if stripped := stripInsidersMetaFromTool(tool); stripped != nil { + if stripped := stripMetaKeys(tool, mcpAppsMetaKeys); stripped != nil { result = append(result, *stripped) } else { result = append(result, tool) @@ -401,30 +406,30 @@ func stripInsidersFeatures(tools []ServerTool) []ServerTool { return result } -// stripInsidersMetaFromTool removes insiders-only Meta keys from a single tool. +// stripMetaKeys removes the specified Meta keys from a single tool. // Returns a modified copy if changes were made, nil otherwise. -func stripInsidersMetaFromTool(tool ServerTool) *ServerTool { - if tool.Tool.Meta == nil { +func stripMetaKeys(tool ServerTool, keys []string) *ServerTool { + if tool.Tool.Meta == nil || len(keys) == 0 { return nil } - // Check if any insiders-only keys exist - hasInsidersKeys := false - for _, key := range insidersOnlyMetaKeys { - if tool.Tool.Meta[key] != nil { - hasInsidersKeys = true + // Check if any of the specified keys exist + hasKeys := false + for _, key := range keys { + if _, ok := tool.Tool.Meta[key]; ok { + hasKeys = true break } } - if !hasInsidersKeys { + if !hasKeys { return nil } - // Make a shallow copy and remove insiders-only keys + // Make a shallow copy and remove specified keys toolCopy := tool newMeta := make(map[string]any, len(tool.Tool.Meta)) for k, v := range tool.Tool.Meta { - if !slices.Contains(insidersOnlyMetaKeys, k) { + if !slices.Contains(keys, k) { newMeta[k] = v } } diff --git a/pkg/inventory/filters.go b/pkg/inventory/filters.go index 707457853c..fd3579fa6f 100644 --- a/pkg/inventory/filters.go +++ b/pkg/inventory/filters.go @@ -36,28 +36,52 @@ func (r *Inventory) checkFeatureFlag(ctx context.Context, flagName string) bool return enabled } -// isFeatureFlagAllowed checks if an item passes feature flag filtering. -// - If FeatureFlagEnable is set, the item is only allowed if the flag is enabled -// - If FeatureFlagDisable is set, the item is excluded if the flag is enabled -func (r *Inventory) isFeatureFlagAllowed(ctx context.Context, enableFlag, disableFlag string) bool { - // Check enable flag - item requires this flag to be on - if enableFlag != "" && !r.checkFeatureFlag(ctx, enableFlag) { - return false +// featureFlagAllowed reports whether an item with the given enable/disable +// flag pair is permitted under the supplied checker. The checker must be +// non-nil — callers that don't want feature filtering should not call this at +// all (this is also the contract for createFeatureFlagFilter, which is only +// installed when WithFeatureChecker received a non-nil checker). +// +// - If FeatureFlagEnable is set, the item is only allowed if the flag is enabled. +// - If FeatureFlagDisable is non-empty, the item is excluded if any listed flag is enabled. +func featureFlagAllowed(ctx context.Context, checker FeatureFlagChecker, enableFlag string, disableFlags []string) bool { + // Error semantics match the previous checkFeatureFlag helper: a checker + // error is logged and treated as "flag not enabled". So an enable-flag + // check on error excludes the tool, but a disable-flag check on error + // keeps it (the disable condition wasn't met). + check := func(flag string) bool { + enabled, err := checker(ctx, flag) + if err != nil { + fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", flag, err) + return false + } + return enabled } - // Check disable flag - item is excluded if this flag is on - if disableFlag != "" && r.checkFeatureFlag(ctx, disableFlag) { + if enableFlag != "" && !check(enableFlag) { return false } - return true + return !slices.ContainsFunc(disableFlags, check) +} + +// createFeatureFlagFilter returns a ToolFilter that gates tools on their +// FeatureFlagEnable / FeatureFlagDisable annotations using the given checker. +// Builder.Build() installs this filter exactly once when WithFeatureChecker +// has been called with a non-nil checker, so "no feature filtering" is +// expressed structurally — by the absence of the filter — rather than by a +// runtime nil check inside the filter itself. +func createFeatureFlagFilter(checker FeatureFlagChecker) ToolFilter { + return func(ctx context.Context, tool *ServerTool) (bool, error) { + return featureFlagAllowed(ctx, checker, tool.FeatureFlagEnable, tool.FeatureFlagDisable), nil + } } // isToolEnabled checks if a specific tool is enabled based on current filters. // Filter evaluation order: // 1. Tool.Enabled (tool self-filtering) -// 2. FeatureFlagEnable/FeatureFlagDisable -// 3. Read-only filter -// 4. Builder filters (via WithFilter) -// 5. Toolset/additional tools +// 2. Read-only filter +// 3. Builder filters (via WithFilter; the feature-flag filter, when +// installed via WithFeatureChecker, runs as part of this step) +// 4. Toolset/additional tools func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool) bool { // 1. Check tool's own Enabled function first if tool.Enabled != nil { @@ -70,15 +94,11 @@ func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool) bool { return false } } - // 2. Check feature flags - if !r.isFeatureFlagAllowed(ctx, tool.FeatureFlagEnable, tool.FeatureFlagDisable) { - return false - } - // 3. Check read-only filter (applies to all tools) + // 2. Check read-only filter (applies to all tools) if r.readOnly && !tool.IsReadOnly() { return false } - // 4. Apply builder filters + // 3. Apply builder filters (includes the feature-flag filter when set) for _, filter := range r.filters { allowed, err := filter(ctx, tool) if err != nil { @@ -89,17 +109,38 @@ func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool) bool { return false } } - // 5. Check if tool is in additionalTools (bypasses toolset filter) + // 4. Check if tool is in additionalTools (bypasses toolset filter) if r.additionalTools != nil && r.additionalTools[tool.Tool.Name] { return true } - // 5. Check toolset filter + // 4. Check toolset filter if !r.isToolsetEnabled(tool.Toolset.ID) { return false } return true } +// sortByToolsetThenName sorts items deterministically by their toolset ID, +// breaking ties by name. The two extractor closures keep this generic helper +// independent of the concrete inventory item shape (tools, resource templates, +// prompts). +func sortByToolsetThenName[T any](items []T, toolsetID func(T) ToolsetID, name func(T) string) { + sort.Slice(items, func(i, j int) bool { + idI, idJ := toolsetID(items[i]), toolsetID(items[j]) + if idI != idJ { + return idI < idJ + } + return name(items[i]) < name(items[j]) + }) +} + +func sortTools(tools []ServerTool) { + sortByToolsetThenName(tools, + func(t ServerTool) ToolsetID { return t.Toolset.ID }, + func(t ServerTool) string { return t.Tool.Name }, + ) +} + // AvailableTools returns the tools that pass all current filters, // sorted deterministically by toolset ID, then tool name. // The context is used for feature flag evaluation. @@ -113,16 +154,18 @@ func (r *Inventory) AvailableTools(ctx context.Context) []ServerTool { } // Sort deterministically: by toolset ID, then by tool name - sort.Slice(result, func(i, j int) bool { - if result[i].Toolset.ID != result[j].Toolset.ID { - return result[i].Toolset.ID < result[j].Toolset.ID - } - return result[i].Tool.Name < result[j].Tool.Name - }) + sortTools(result) return result } +func sortResourceTemplates(resourceTemplates []ServerResourceTemplate) { + sortByToolsetThenName(resourceTemplates, + func(r ServerResourceTemplate) ToolsetID { return r.Toolset.ID }, + func(r ServerResourceTemplate) string { return r.Template.Name }, + ) +} + // AvailableResourceTemplates returns resource templates that pass all current filters, // sorted deterministically by toolset ID, then template name. // The context is used for feature flag evaluation. @@ -130,8 +173,11 @@ func (r *Inventory) AvailableResourceTemplates(ctx context.Context) []ServerReso var result []ServerResourceTemplate for i := range r.resourceTemplates { res := &r.resourceTemplates[i] - // Check feature flags - if !r.isFeatureFlagAllowed(ctx, res.FeatureFlagEnable, res.FeatureFlagDisable) { + // Resources have no filter pipeline, so feature gating runs inline. + // The featureChecker != nil guard mirrors the structural "no checker + // = no filtering" contract used for tools (where the absence of a + // pipeline step expresses the same thing). + if r.featureChecker != nil && !featureFlagAllowed(ctx, r.featureChecker, res.FeatureFlagEnable, res.FeatureFlagDisable) { continue } if r.isToolsetEnabled(res.Toolset.ID) { @@ -140,16 +186,18 @@ func (r *Inventory) AvailableResourceTemplates(ctx context.Context) []ServerReso } // Sort deterministically: by toolset ID, then by template name - sort.Slice(result, func(i, j int) bool { - if result[i].Toolset.ID != result[j].Toolset.ID { - return result[i].Toolset.ID < result[j].Toolset.ID - } - return result[i].Template.Name < result[j].Template.Name - }) + sortResourceTemplates(result) return result } +func sortPrompts(prompts []ServerPrompt) { + sortByToolsetThenName(prompts, + func(p ServerPrompt) ToolsetID { return p.Toolset.ID }, + func(p ServerPrompt) string { return p.Prompt.Name }, + ) +} + // AvailablePrompts returns prompts that pass all current filters, // sorted deterministically by toolset ID, then prompt name. // The context is used for feature flag evaluation. @@ -157,8 +205,9 @@ func (r *Inventory) AvailablePrompts(ctx context.Context) []ServerPrompt { var result []ServerPrompt for i := range r.prompts { prompt := &r.prompts[i] - // Check feature flags - if !r.isFeatureFlagAllowed(ctx, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable) { + // Prompts have no filter pipeline; see AvailableResourceTemplates for + // the rationale behind the explicit nil guard. + if r.featureChecker != nil && !featureFlagAllowed(ctx, r.featureChecker, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable) { continue } if r.isToolsetEnabled(prompt.Toolset.ID) { @@ -167,12 +216,7 @@ func (r *Inventory) AvailablePrompts(ctx context.Context) []ServerPrompt { } // Sort deterministically: by toolset ID, then by prompt name - sort.Slice(result, func(i, j int) bool { - if result[i].Toolset.ID != result[j].Toolset.ID { - return result[i].Toolset.ID < result[j].Toolset.ID - } - return result[i].Prompt.Name < result[j].Prompt.Name - }) + sortPrompts(result) return result } @@ -215,62 +259,6 @@ func (r *Inventory) filterPromptsByName(name string) []ServerPrompt { return []ServerPrompt{} } -// ToolsForToolset returns all tools belonging to a specific toolset. -// This method bypasses the toolset enabled filter (for dynamic toolset registration), -// but still respects the read-only filter. -func (r *Inventory) ToolsForToolset(toolsetID ToolsetID) []ServerTool { - var result []ServerTool - for i := range r.tools { - tool := &r.tools[i] - // Only check read-only filter, not toolset enabled filter - if tool.Toolset.ID == toolsetID { - if r.readOnly && !tool.IsReadOnly() { - continue - } - result = append(result, *tool) - } - } - - // Sort by tool name for deterministic order - sort.Slice(result, func(i, j int) bool { - return result[i].Tool.Name < result[j].Tool.Name - }) - - return result -} - -// IsToolsetEnabled checks if a toolset is currently enabled based on filters. -func (r *Inventory) IsToolsetEnabled(toolsetID ToolsetID) bool { - return r.isToolsetEnabled(toolsetID) -} - -// EnableToolset marks a toolset as enabled in this group. -// This is used by dynamic toolset management to track which toolsets have been enabled. -func (r *Inventory) EnableToolset(toolsetID ToolsetID) { - if r.enabledToolsets == nil { - // nil means all enabled, so nothing to do - return - } - r.enabledToolsets[toolsetID] = true -} - -// EnabledToolsetIDs returns the list of enabled toolset IDs based on current filters. -// Returns all toolset IDs if no filter is set. -func (r *Inventory) EnabledToolsetIDs() []ToolsetID { - if r.enabledToolsets == nil { - return r.ToolsetIDs() - } - - ids := make([]ToolsetID, 0, len(r.enabledToolsets)) - for id := range r.enabledToolsets { - if r.HasToolset(id) { - ids = append(ids, id) - } - } - slices.Sort(ids) - return ids -} - // FilteredTools returns tools filtered by the Enabled function and builder filters. // This provides an explicit API for accessing filtered tools, currently implemented // as an alias for AvailableTools. diff --git a/pkg/inventory/prompts.go b/pkg/inventory/prompts.go index 648f20f9cd..d929578e83 100644 --- a/pkg/inventory/prompts.go +++ b/pkg/inventory/prompts.go @@ -11,9 +11,9 @@ type ServerPrompt struct { // FeatureFlagEnable specifies a feature flag that must be enabled for this prompt // to be available. If set and the flag is not enabled, the prompt is omitted. FeatureFlagEnable string - // FeatureFlagDisable specifies a feature flag that, when enabled, causes this prompt - // to be omitted. Used to disable prompts when a feature flag is on. - FeatureFlagDisable string + // FeatureFlagDisable specifies feature flags that, when any is enabled, cause this + // prompt to be omitted. Used to disable prompts when a feature flag is on. + FeatureFlagDisable []string } // NewServerPrompt creates a new ServerPrompt with toolset metadata. diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index e2cd3a9e67..b8a70a3420 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -7,6 +7,7 @@ import ( "slices" "sort" + ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -23,7 +24,6 @@ import ( // - Filtered access to tools/resources/prompts via Available* methods // - Deterministic ordering for documentation generation // - Lazy dependency injection during registration via RegisterAll() -// - Runtime toolset enabling for dynamic toolsets mode type Inventory struct { // tools holds all tools in this group (ordered for iteration) tools []ServerTool @@ -168,10 +168,54 @@ func (r *Inventory) ToolsetDescriptions() map[ToolsetID]string { return r.toolsetDescriptions } +// ToolsForRegistration returns AvailableTools(ctx) post-processed exactly as +// RegisterTools would expose them: with MCP Apps UI metadata stripped when +// the client cannot consume it. Useful for documentation generators and +// diagnostics that need the same view of the tool surface the server would +// register. +// +// The strip applies when EITHER of the following is true: +// +// - The remote_mcp_ui_apps feature flag is not enabled in ctx (server-side gate). +// - The client explicitly did not advertise the io.modelcontextprotocol/ui +// extension capability (per the 2026-01-26 MCP Apps spec, servers SHOULD +// check client capabilities before exposing UI-enabled tools). When the +// capability is unknown (e.g. stdio paths that do not populate the +// context flag) the feature-flag gate is the sole source of truth. +func (r *Inventory) ToolsForRegistration(ctx context.Context) []ServerTool { + tools := r.AvailableTools(ctx) + if shouldStripMCPAppsMetadata(ctx, r.checkFeatureFlag(ctx, mcpAppsFeatureFlag)) { + tools = stripMCPAppsMetadata(tools) + } + return tools +} + +// shouldStripMCPAppsMetadata centralises the strip decision so the same logic +// is exercised by tests and by RegisterTools. +func shouldStripMCPAppsMetadata(ctx context.Context, featureFlagEnabled bool) bool { + if !featureFlagEnabled { + return true + } + // Feature flag is on. Respect the client capability if it is known. + if supported, ok := ghcontext.HasUISupport(ctx); ok && !supported { + return true + } + return false +} + // RegisterTools registers all available tools with the server using the provided dependencies. -// The context is used for feature flag evaluation. +// The context is used for feature flag evaluation and client capability checks. +// +// MCP Apps UI metadata (`_meta.ui`) is stripped from the registered tools +// when either the MCP Apps feature flag is not enabled for this request, or +// the client did not advertise the io.modelcontextprotocol/ui extension. The +// strip happens here (rather than at Build() time) so the per-request +// context is in scope — HTTP feature checkers that read insiders mode or +// user identity from ctx would otherwise see context.Background() and +// falsely report the flag off, even when the actual request arrived on the +// /insiders route. func (r *Inventory) RegisterTools(ctx context.Context, s *mcp.Server, deps any) { - for _, tool := range r.AvailableTools(ctx) { + for _, tool := range r.ToolsForRegistration(ctx) { tool.RegisterFunc(s, deps) } } diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 207e65dba8..20b1fb718c 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -6,6 +6,7 @@ import ( "fmt" "testing" + ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) @@ -38,7 +39,7 @@ func testToolsetMetadataWithDefault(id string, isDefault bool) ToolsetMetadata { // mockToolWithDefault creates a mock tool with a default toolset flag func mockToolWithDefault(name string, toolsetID string, readOnly bool, isDefault bool) ServerTool { - return NewServerToolFromHandler( + return NewServerTool( mcp.Tool{ Name: name, Annotations: &mcp.ToolAnnotations{ @@ -47,17 +48,15 @@ func mockToolWithDefault(name string, toolsetID string, readOnly bool, isDefault InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), }, testToolsetMetadataWithDefault(toolsetID, isDefault), - func(_ any) mcp.ToolHandler { - return func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return nil, nil - } + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil }, ) } // mockTool creates a minimal ServerTool for testing func mockTool(name string, toolsetID string, readOnly bool) ServerTool { - return NewServerToolFromHandler( + return NewServerTool( mcp.Tool{ Name: name, Annotations: &mcp.ToolAnnotations{ @@ -66,10 +65,8 @@ func mockTool(name string, toolsetID string, readOnly bool) ServerTool { InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), }, testToolsetMetadata(toolsetID), - func(_ any) mcp.ToolHandler { - return func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return nil, nil - } + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil }, ) } @@ -466,21 +463,6 @@ func TestToolsetDescriptions(t *testing.T) { } } -func TestToolsForToolset(t *testing.T) { - tools := []ServerTool{ - mockTool("tool1", "toolset1", true), - mockTool("tool2", "toolset1", true), - mockTool("tool3", "toolset2", true), - } - - reg := mustBuild(t, NewBuilder().SetTools(tools)) - toolset1Tools := reg.ToolsForToolset("toolset1") - - if len(toolset1Tools) != 2 { - t.Fatalf("Expected 2 tools for toolset1, got %d", len(toolset1Tools)) - } -} - func TestWithDeprecatedAliases(t *testing.T) { tools := []ServerTool{ mockTool("new_name", "toolset1", true), @@ -642,30 +624,6 @@ func TestHasToolset(t *testing.T) { } } -func TestEnabledToolsetIDs(t *testing.T) { - tools := []ServerTool{ - mockTool("tool1", "toolset1", true), - mockTool("tool2", "toolset2", true), - } - - // Without filter, all toolsets are enabled - 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 := 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)) - } - if filteredIDs[0] != "toolset1" { - t.Errorf("Expected toolset1, got %s", filteredIDs[0]) - } -} - func TestAllTools(t *testing.T) { tools := []ServerTool{ mockTool("read_tool", "toolset1", true), @@ -1090,7 +1048,9 @@ func TestMCPMethodConstants(t *testing.T) { func mockToolWithFlags(name string, toolsetID string, readOnly bool, enableFlag, disableFlag string) ServerTool { tool := mockTool(name, toolsetID, readOnly) tool.FeatureFlagEnable = enableFlag - tool.FeatureFlagDisable = disableFlag + if disableFlag != "" { + tool.FeatureFlagDisable = []string{disableFlag} + } return tool } @@ -1100,23 +1060,23 @@ func TestFeatureFlagEnable(t *testing.T) { mockToolWithFlags("needs_flag", "toolset1", true, "my_feature", ""), } - // Without feature checker, tool with FeatureFlagEnable should be excluded + // Without feature checker, feature-flag filtering is skipped: both tools pass 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)) - } - if available[0].Tool.Name != "always_available" { - t.Errorf("Expected always_available, got %s", available[0].Tool.Name) + if len(available) != 2 { + t.Fatalf("Expected 2 tools without feature checker (filtering skipped), got %d", len(available)) } - // With feature checker returning false, tool should still be excluded + // With feature checker returning false, FeatureFlagEnable tool is excluded checkerFalse := func(_ context.Context, _ string) (bool, error) { return false, nil } 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)) } + if availableFalse[0].Tool.Name != "always_available" { + t.Errorf("Expected always_available, got %s", availableFalse[0].Tool.Name) + } // With feature checker returning true for "my_feature", tool should be included checkerTrue := func(_ context.Context, flag string) (bool, error) { @@ -1210,11 +1170,11 @@ func TestFeatureFlagResources(t *testing.T) { }, } - // Without checker, resource with enable flag should be excluded + // Without checker, feature-flag filtering is skipped: both resources pass 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)) + if len(available) != 2 { + t.Fatalf("Expected 2 resources without checker (filtering skipped), got %d", len(available)) } // With checker returning true, both should be included @@ -1235,11 +1195,11 @@ func TestFeatureFlagPrompts(t *testing.T) { }, } - // Without checker, prompt with enable flag should be excluded + // Without checker, feature-flag filtering is skipped: both prompts pass 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)) + if len(available) != 2 { + t.Fatalf("Expected 2 prompts without checker (filtering skipped), got %d", len(available)) } // With checker returning true, both should be included @@ -1525,9 +1485,11 @@ func TestEnabledAndFeatureFlagInteraction(t *testing.T) { } // Feature flag not enabled - tool should be excluded despite Enabled returning true + checkerOff := func(_ context.Context, _ string) (bool, error) { return false, nil } reg1 := mustBuild(t, NewBuilder(). SetTools([]ServerTool{tool}). - WithToolsets([]string{"all"})) + WithToolsets([]string{"all"}). + WithFeatureChecker(checkerOff)) available1 := reg1.AvailableTools(context.Background()) if len(available1) != 0 { t.Error("Tool should be excluded when feature flag is not enabled") @@ -1693,10 +1655,10 @@ func TestFilteredToolsMatchesAvailableTools(t *testing.T) { func TestFilteringOrder(t *testing.T) { // Test that filters are applied in the correct order: // 1. Tool.Enabled - // 2. Feature flags - // 3. Read-only - // 4. Builder filters - // 5. Toolset/additional tools + // 2. Read-only + // 3. Builder filters (feature-flag filter is at the head of this list + // when WithFeatureChecker is set) + // 4. Toolset/additional tools callOrder := []string{} @@ -1723,10 +1685,15 @@ func TestFilteringOrder(t *testing.T) { WithFeatureChecker(checker). WithFilter(filter)) + // Reset call order — Build() may call the checker for MCP Apps metadata. + // We're testing the AvailableTools filter order here. + callOrder = callOrder[:0] + _ = reg.AvailableTools(context.Background()) - // Expected order: Enabled, FeatureFlag, ReadOnly (stops here because it's write tool) - expectedOrder := []string{"Enabled", "FeatureFlag"} + // Expected order: Enabled, then Read-only stops (write tool, read-only mode); + // neither the feature-flag filter nor the user filter is reached. + expectedOrder := []string{"Enabled"} if len(callOrder) != len(expectedOrder) { t.Errorf("Expected %d checks, got %d: %v", len(expectedOrder), len(callOrder), callOrder) } @@ -1749,16 +1716,18 @@ func TestForMCPRequest_ToolsCall_FeatureFlaggedVariants(t *testing.T) { } // Test 1: Flag is OFF - first tool variant should be available + checkerOff := func(_ context.Context, _ string) (bool, error) { return false, nil } regFlagOff := mustBuild(t, NewBuilder(). SetTools(tools). - WithToolsets([]string{"all"})) + WithToolsets([]string{"all"}). + WithFeatureChecker(checkerOff)) 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", + if len(availableOff[0].FeatureFlagDisable) != 1 || availableOff[0].FeatureFlagDisable[0] != "consolidated_flag" { + t.Errorf("Flag OFF: Expected tool with FeatureFlagDisable, got FeatureFlagEnable=%q, FeatureFlagDisable=%v", availableOff[0].FeatureFlagEnable, availableOff[0].FeatureFlagDisable) } @@ -1776,7 +1745,7 @@ func TestForMCPRequest_ToolsCall_FeatureFlaggedVariants(t *testing.T) { 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", + t.Errorf("Flag ON: Expected tool with FeatureFlagEnable, got FeatureFlagEnable=%q, FeatureFlagDisable=%v", availableOn[0].FeatureFlagEnable, availableOn[0].FeatureFlagDisable) } } @@ -1801,11 +1770,13 @@ func TestWithTools_DeprecatedAliasAndFeatureFlag(t *testing.T) { // 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) + checkerOff := func(_ context.Context, _ string) (bool, error) { return false, nil } regFlagOff := mustBuild(t, NewBuilder(). SetTools(tools). WithDeprecatedAliases(deprecatedAliases). WithToolsets([]string{}). // No toolsets enabled - WithTools([]string{"old_tool"})) // Explicitly request old tool + WithTools([]string{"old_tool"}). // Explicitly request old tool + WithFeatureChecker(checkerOff)) availableOff := regFlagOff.AvailableTools(context.Background()) if len(availableOff) != 1 { t.Fatalf("Flag OFF: Expected 1 tool, got %d", len(availableOff)) @@ -1835,7 +1806,7 @@ func TestWithTools_DeprecatedAliasAndFeatureFlag(t *testing.T) { // mockToolWithMeta creates a ServerTool with Meta for testing insiders mode func mockToolWithMeta(name string, toolsetID string, meta map[string]any) ServerTool { - return NewServerToolFromHandler( + return NewServerTool( mcp.Tool{ Name: name, Annotations: &mcp.ToolAnnotations{ @@ -1845,53 +1816,52 @@ func mockToolWithMeta(name string, toolsetID string, meta map[string]any) Server Meta: meta, }, testToolsetMetadata(toolsetID), - func(_ any) mcp.ToolHandler { - return func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return nil, nil - } + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil }, ) } -func TestWithInsidersMode_DisabledStripsUIMetadata(t *testing.T) { +func TestWithMCPApps_DisabledStripsUIMetadata(t *testing.T) { toolWithUI := mockToolWithMeta("tool_with_ui", "toolset1", map[string]any{ "ui": map[string]any{"html": "
hello
"}, "description": "kept", }) - // Default: insiders mode is disabled - UI meta should be stripped + // Default: MCP Apps is disabled - UI meta should be stripped on registration. reg := mustBuild(t, NewBuilder().SetTools([]ServerTool{toolWithUI}).WithToolsets([]string{"all"})) - available := reg.AvailableTools(context.Background()) + registered := captureRegisteredTools(context.Background(), t, reg) - require.Len(t, available, 1) - // UI metadata should be stripped - if available[0].Tool.Meta["ui"] != nil { + require.Len(t, registered, 1) + if registered[0].Meta["ui"] != nil { t.Errorf("Expected 'ui' meta to be stripped, but it was present") } - // Other metadata should be preserved - if available[0].Tool.Meta["description"] != "kept" { - t.Errorf("Expected 'description' meta to be preserved, got %v", available[0].Tool.Meta["description"]) + if registered[0].Meta["description"] != "kept" { + t.Errorf("Expected 'description' meta to be preserved, got %v", registered[0].Meta["description"]) } } -func TestWithInsidersMode_EnabledPreservesUIMetadata(t *testing.T) { +func TestWithMCPApps_EnabledPreservesUIMetadata(t *testing.T) { uiData := map[string]any{"html": "
hello
"} toolWithUI := mockToolWithMeta("tool_with_ui", "toolset1", map[string]any{ "ui": uiData, "description": "kept", }) - // Insiders mode enabled - UI meta should be preserved + // Feature checker enables MCP Apps - UI meta should be preserved + mcpAppsChecker := func(_ context.Context, flag string) (bool, error) { + return flag == mcpAppsFeatureFlag, nil + } reg := mustBuild(t, NewBuilder(). SetTools([]ServerTool{toolWithUI}). WithToolsets([]string{"all"}). - WithInsidersMode(true)) + WithFeatureChecker(mcpAppsChecker)) available := reg.AvailableTools(context.Background()) require.Len(t, available, 1) // UI metadata should be preserved if available[0].Tool.Meta["ui"] == nil { - t.Errorf("Expected 'ui' meta to be preserved in insiders mode") + t.Errorf("Expected 'ui' meta to be preserved with MCP Apps enabled") } // Other metadata should also be preserved if available[0].Tool.Meta["description"] != "kept" { @@ -1899,48 +1869,14 @@ func TestWithInsidersMode_EnabledPreservesUIMetadata(t *testing.T) { } } -func TestWithInsidersMode_EnabledPreservesInsidersOnlyTools(t *testing.T) { - normalTool := mockTool("normal", "toolset1", true) - insidersTool := mockTool("insiders_only", "toolset1", true) - insidersTool.InsidersOnly = true - - // With insiders mode enabled, both tools should be available - reg := mustBuild(t, NewBuilder(). - SetTools([]ServerTool{normalTool, insidersTool}). - WithToolsets([]string{"all"}). - WithInsidersMode(true)) - available := reg.AvailableTools(context.Background()) - - require.Len(t, available, 2) - names := []string{available[0].Tool.Name, available[1].Tool.Name} - require.Contains(t, names, "normal") - require.Contains(t, names, "insiders_only") -} - -func TestWithInsidersMode_DisabledRemovesInsidersOnlyTools(t *testing.T) { - normalTool := mockTool("normal", "toolset1", true) - insidersTool := mockTool("insiders_only", "toolset1", true) - insidersTool.InsidersOnly = true - - // With insiders mode disabled, insiders-only tool should be removed - reg := mustBuild(t, NewBuilder(). - SetTools([]ServerTool{normalTool, insidersTool}). - WithToolsets([]string{"all"}). - WithInsidersMode(false)) - available := reg.AvailableTools(context.Background()) - - require.Len(t, available, 1) - require.Equal(t, "normal", available[0].Tool.Name) -} - -func TestWithInsidersMode_ToolsWithoutUIMetaUnaffected(t *testing.T) { +func TestWithMCPApps_ToolsWithoutUIMetaUnaffected(t *testing.T) { toolNoUI := mockToolWithMeta("tool_no_ui", "toolset1", map[string]any{ "description": "kept", "version": "1.0", }) toolNilMeta := mockTool("tool_nil_meta", "toolset1", true) - // Test with insiders disabled + // Test with MCP Apps disabled (default) - non-UI meta should be unaffected reg := mustBuild(t, NewBuilder(). SetTools([]ServerTool{toolNoUI, toolNilMeta}). WithToolsets([]string{"all"})) @@ -1973,8 +1909,7 @@ func TestWithInsidersMode_ToolsWithoutUIMetaUnaffected(t *testing.T) { } } -func TestWithInsidersMode_UIOnlyMetaBecomesNil(t *testing.T) { - // Tool with ONLY ui metadata - should become nil after stripping +func TestWithMCPApps_UIOnlyMetaBecomesNil(t *testing.T) { toolUIOnly := mockToolWithMeta("tool_ui_only", "toolset1", map[string]any{ "ui": map[string]any{"html": "
hello
"}, }) @@ -1982,47 +1917,59 @@ func TestWithInsidersMode_UIOnlyMetaBecomesNil(t *testing.T) { reg := mustBuild(t, NewBuilder(). SetTools([]ServerTool{toolUIOnly}). WithToolsets([]string{"all"})) - available := reg.AvailableTools(context.Background()) + registered := captureRegisteredTools(context.Background(), t, reg) - require.Len(t, available, 1) - // Meta should be nil since ui was the only key - if available[0].Tool.Meta != nil { - t.Errorf("Expected Meta to be nil after stripping only key, got %v", available[0].Tool.Meta) + require.Len(t, registered, 1) + if registered[0].Meta != nil { + t.Errorf("Expected Meta to be nil after stripping only key, got %v", registered[0].Meta) } } -func TestStripInsidersMetaFromTool(t *testing.T) { +func TestStripMetaKeys(t *testing.T) { tests := []struct { name string meta map[string]any + keys []string expectChange bool expectedMeta map[string]any // nil means Meta should be nil }{ { name: "nil meta - no change", meta: nil, + keys: mcpAppsMetaKeys, expectChange: false, }, { - name: "no insiders keys - no change", + name: "no matching keys - no change", meta: map[string]any{"description": "test", "version": "1.0"}, + keys: mcpAppsMetaKeys, expectChange: false, }, { name: "ui key only - becomes nil", meta: map[string]any{"ui": "data"}, + keys: mcpAppsMetaKeys, expectChange: true, expectedMeta: nil, }, { name: "ui key with other keys - ui stripped", meta: map[string]any{"ui": "data", "description": "kept"}, + keys: mcpAppsMetaKeys, expectChange: true, expectedMeta: map[string]any{"description": "kept"}, }, { - name: "ui is nil value - no change (nil value means key not present)", + name: "ui is nil value - ui stripped", meta: map[string]any{"ui": nil, "description": "kept"}, + keys: mcpAppsMetaKeys, + expectChange: true, + expectedMeta: map[string]any{"description": "kept"}, + }, + { + name: "empty keys list - no change", + meta: map[string]any{"ui": "data"}, + keys: []string{}, expectChange: false, }, } @@ -2030,7 +1977,7 @@ func TestStripInsidersMetaFromTool(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tool := mockToolWithMeta("test", "toolset1", tt.meta) - result := stripInsidersMetaFromTool(tool) + result := stripMetaKeys(tool, tt.keys) if tt.expectChange { require.NotNil(t, result, "expected change but got nil") @@ -2050,14 +1997,14 @@ func TestStripInsidersMetaFromTool(t *testing.T) { } } -func TestStripInsidersFeatures(t *testing.T) { +func TestStripMCPAppsMetadata(t *testing.T) { tools := []ServerTool{ mockToolWithMeta("tool1", "toolset1", map[string]any{"ui": "data"}), mockToolWithMeta("tool2", "toolset1", map[string]any{"description": "kept"}), mockTool("tool3", "toolset1", true), // nil meta } - result := stripInsidersFeatures(tools) + result := stripMCPAppsMetadata(tools) require.Len(t, result, 3) @@ -2072,33 +2019,9 @@ func TestStripInsidersFeatures(t *testing.T) { require.Nil(t, result[2].Tool.Meta) } -func TestStripInsidersFeatures_RemovesInsidersOnlyTools(t *testing.T) { - // Create tools: one normal, one insiders-only, one normal - normalTool1 := mockTool("normal1", "toolset1", true) - insidersTool := mockTool("insiders_only", "toolset1", true) - insidersTool.InsidersOnly = true - normalTool2 := mockTool("normal2", "toolset1", true) - - tools := []ServerTool{normalTool1, insidersTool, normalTool2} - - result := stripInsidersFeatures(tools) - - // Should only have 2 tools (insiders-only tool filtered out) - require.Len(t, result, 2) - require.Equal(t, "normal1", result[0].Tool.Name) - require.Equal(t, "normal2", result[1].Tool.Name) -} - -func TestInsidersOnlyMetaKeys_FutureAdditions(t *testing.T) { +func TestStripMetaKeys_MultipleKeys(t *testing.T) { // This test verifies the mechanism works for multiple keys - // If we add new experimental keys to insidersOnlyMetaKeys, they should be stripped - - // Save original and restore after test - originalKeys := insidersOnlyMetaKeys - defer func() { insidersOnlyMetaKeys = originalKeys }() - - // Add a hypothetical future experimental key - insidersOnlyMetaKeys = []string{"ui", "experimental_feature", "beta"} + keys := []string{"ui", "experimental_feature", "beta"} tool := mockToolWithMeta("test", "toolset1", map[string]any{ "ui": "ui data", @@ -2107,7 +2030,7 @@ func TestInsidersOnlyMetaKeys_FutureAdditions(t *testing.T) { "description": "kept", }) - result := stripInsidersMetaFromTool(tool) + result := stripMetaKeys(tool, keys) require.NotNil(t, result) require.NotNil(t, result.Tool.Meta) @@ -2117,12 +2040,12 @@ func TestInsidersOnlyMetaKeys_FutureAdditions(t *testing.T) { require.Equal(t, "kept", result.Tool.Meta["description"], "description should be preserved") } -func TestWithInsidersMode_DoesNotMutateOriginalTools(t *testing.T) { +func TestWithMCPApps_DoesNotMutateOriginalTools(t *testing.T) { originalMeta := map[string]any{"ui": "data", "description": "kept"} tool := mockToolWithMeta("test", "toolset1", originalMeta) tools := []ServerTool{tool} - // Build with insiders disabled - should strip ui + // Build with MCP Apps disabled (default) - should strip ui _ = mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) // Original tool should be unchanged @@ -2277,3 +2200,77 @@ func TestCreateExcludeToolsFilter(t *testing.T) { require.NoError(t, err) require.True(t, allowed, "allowed_tool should be included") } + +// captureRegisteredTools mirrors RegisterTools' per-request strip behavior so +// tests can verify what the wire sees, without requiring tools to have real +// handlers (RegisterTools panics on tools without HandlerFunc). +func captureRegisteredTools(ctx context.Context, t *testing.T, reg *Inventory) []*mcp.Tool { + t.Helper() + tools := reg.AvailableTools(ctx) + out := make([]*mcp.Tool, 0, len(tools)) + for i := range tools { + toolCopy := tools[i].Tool + out = append(out, &toolCopy) + } + if shouldStripMCPAppsMetadata(ctx, reg.checkFeatureFlag(ctx, mcpAppsFeatureFlag)) { + for _, tt := range out { + delete(tt.Meta, "ui") + if len(tt.Meta) == 0 { + tt.Meta = nil + } + } + } + return out +} + +// TestShouldStripMCPAppsMetadata verifies the spec-conformant strip decision: +// strip when the feature flag is off, OR when the client explicitly does not +// advertise the io.modelcontextprotocol/ui extension. +func TestShouldStripMCPAppsMetadata(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupCtx func() context.Context + ffOn bool + want bool + }{ + { + name: "FF off, capability unknown -> strip", + setupCtx: context.Background, + ffOn: false, + want: true, + }, + { + name: "FF off, capability present -> strip (FF wins)", + setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), true) }, + ffOn: false, + want: true, + }, + { + name: "FF on, capability unknown -> keep", + setupCtx: context.Background, + ffOn: true, + want: false, + }, + { + name: "FF on, capability present -> keep", + setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), true) }, + ffOn: true, + want: false, + }, + { + name: "FF on, capability explicitly absent -> strip", + setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), false) }, + ffOn: true, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := shouldStripMCPAppsMetadata(tc.setupCtx(), tc.ffOn) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/pkg/inventory/resources.go b/pkg/inventory/resources.go index 6de037d584..2dd07ae0fe 100644 --- a/pkg/inventory/resources.go +++ b/pkg/inventory/resources.go @@ -19,9 +19,9 @@ type ServerResourceTemplate struct { // FeatureFlagEnable specifies a feature flag that must be enabled for this resource // to be available. If set and the flag is not enabled, the resource is omitted. FeatureFlagEnable string - // FeatureFlagDisable specifies a feature flag that, when enabled, causes this resource - // to be omitted. Used to disable resources when a feature flag is on. - FeatureFlagDisable string + // FeatureFlagDisable specifies feature flags that, when any is enabled, cause this + // resource to be omitted. Used to disable resources when a feature flag is on. + FeatureFlagDisable []string } // HasHandler returns true if this resource has a handler function. diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index b08ae1f014..326009b59f 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -3,6 +3,7 @@ package inventory import ( "context" "encoding/json" + "fmt" "github.com/github/github-mcp-server/pkg/octicons" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -63,9 +64,9 @@ type ServerTool struct { // to be available. If set and the flag is not enabled, the tool is omitted. FeatureFlagEnable string - // FeatureFlagDisable specifies a feature flag that, when enabled, causes this tool - // to be omitted. Used to disable tools when a feature flag is on. - FeatureFlagDisable string + // FeatureFlagDisable specifies feature flags that, when any is enabled, cause this + // tool to be omitted. Used to disable tools when a feature flag is on. + FeatureFlagDisable []string // Enabled is an optional function called at build/filter time to determine // if this tool should be available. If nil, the tool is considered enabled @@ -82,10 +83,6 @@ type ServerTool struct { // This includes the required scopes plus any higher-level scopes that provide // the necessary permissions due to scope hierarchy. AcceptedScopes []string - - // InsidersOnly marks this tool as only available when insiders mode is enabled. - // When insiders mode is disabled, tools with this flag set are completely omitted. - InsidersOnly bool } // IsReadOnly returns true if this tool is marked as read-only via annotations. @@ -122,30 +119,6 @@ func (st *ServerTool) RegisterFunc(s *mcp.Server, deps any) { s.AddTool(&toolCopy, handler) } -// NewServerTool creates a ServerTool from a tool definition, toolset metadata, and a typed handler function. -// The handler function takes dependencies (as any) and returns a typed handler. -// Callers should type-assert deps to their typed dependencies struct. -// -// Deprecated: This creates closures at registration time. For better performance in -// per-request server scenarios, use NewServerToolWithContextHandler instead. -func NewServerTool[In any, Out any](tool mcp.Tool, toolset ToolsetMetadata, handlerFn func(deps any) mcp.ToolHandlerFor[In, Out]) ServerTool { - return ServerTool{ - Tool: tool, - Toolset: toolset, - HandlerFunc: func(deps any) mcp.ToolHandler { - typedHandler := handlerFn(deps) - return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var arguments In - if err := json.Unmarshal(req.Params.Arguments, &arguments); err != nil { - return nil, err - } - resp, _, err := typedHandler(ctx, req, arguments) - return resp, err - } - }, - } -} - // NewServerToolWithContextHandler creates a ServerTool with a handler that receives deps via context. // This is the preferred approach for tools because it doesn't create closures at registration time, // which is critical for performance in servers that create a new instance per request. @@ -161,7 +134,12 @@ func NewServerToolWithContextHandler[In any, Out any](tool mcp.Tool, toolset Too return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { var arguments In if err := json.Unmarshal(req.Params.Arguments, &arguments); err != nil { - return nil, err + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("invalid arguments: %s", err)}, + }, + IsError: true, + }, nil } resp, _, err := handler(ctx, req, arguments) return resp, err @@ -170,22 +148,14 @@ func NewServerToolWithContextHandler[In any, Out any](tool mcp.Tool, toolset Too } } -// NewServerToolFromHandler creates a ServerTool from a tool definition, toolset metadata, and a raw handler function. -// Use this when you have a handler that already conforms to mcp.ToolHandler. -// -// Deprecated: This creates closures at registration time. For better performance in -// per-request server scenarios, use NewServerToolWithRawContextHandler instead. -func NewServerToolFromHandler(tool mcp.Tool, toolset ToolsetMetadata, handlerFn func(deps any) mcp.ToolHandler) ServerTool { - return ServerTool{Tool: tool, Toolset: toolset, HandlerFunc: handlerFn} -} - -// NewServerToolWithRawContextHandler creates a ServerTool with a raw handler that receives deps via context. -// This is the preferred approach for tools that use mcp.ToolHandler directly because it doesn't -// create closures at registration time. +// NewServerTool creates a ServerTool with a raw handler that receives deps via context. +// This is the preferred constructor for tools that use mcp.ToolHandler directly because +// it doesn't create closures at registration time, which is critical for performance in +// servers that create a new instance per request. // // The handler function is stored directly without wrapping in a deps closure. // Dependencies should be injected into context before calling tool handlers. -func NewServerToolWithRawContextHandler(tool mcp.Tool, toolset ToolsetMetadata, handler mcp.ToolHandler) ServerTool { +func NewServerTool(tool mcp.Tool, toolset ToolsetMetadata, handler mcp.ToolHandler) ServerTool { return ServerTool{ Tool: tool, Toolset: toolset, diff --git a/pkg/inventory/server_tool_test.go b/pkg/inventory/server_tool_test.go new file mode 100644 index 0000000000..69cee94af0 --- /dev/null +++ b/pkg/inventory/server_tool_test.go @@ -0,0 +1,80 @@ +package inventory + +import ( + "context" + "encoding/json" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewServerToolWithContextHandler_InvalidArguments_ReturnsIsError(t *testing.T) { + type expectedArgs struct { + Query string `json:"query"` + Limit int `json:"limit"` + } + + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "test_context_tool"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, _ expectedArgs) (*mcp.CallToolResult, any, error) { + t.Fatal("handler should not be called with invalid arguments") + return nil, nil, nil + }, + ) + + handler := tool.HandlerFunc(nil) + + result, err := handler(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Name: "test_context_tool", + Arguments: json.RawMessage(`{not valid json`), + }, + }) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Len(t, result.Content, 1) + textContent, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + assert.Contains(t, textContent.Text, "invalid arguments") +} + +func TestNewServerToolWithContextHandler_ValidArguments_Succeeds(t *testing.T) { + type expectedArgs struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + } + + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "test_tool"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, args expectedArgs) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "success: " + args.Owner + "/" + args.Repo}, + }, + }, nil, nil + }, + ) + + handler := tool.HandlerFunc(nil) + + goodArgs, _ := json.Marshal(map[string]any{"owner": "octocat", "repo": "hello-world"}) + result, err := handler(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Name: "test_tool", + Arguments: goodArgs, + }, + }) + + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + textContent, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + assert.Equal(t, "success: octocat/hello-world", textContent.Text) +} diff --git a/pkg/lockdown/lockdown.go b/pkg/lockdown/lockdown.go index 2dceac8aa6..238ccb06ee 100644 --- a/pkg/lockdown/lockdown.go +++ b/pkg/lockdown/lockdown.go @@ -4,36 +4,41 @@ import ( "context" "fmt" "log/slog" + "maps" "strings" "sync" "time" + "github.com/google/go-github/v87/github" "github.com/muesli/cache2go" "github.com/shurcooL/githubv4" ) // RepoAccessCache caches repository metadata related to lockdown checks so that // multiple tools can reuse the same access information safely across goroutines. +// In HTTP mode each request must construct its own instance so viewer-scoped +// lookups run under the requesting user's credentials. type RepoAccessCache struct { client *githubv4.Client - mu sync.Mutex + restClient *github.Client cache *cache2go.CacheTable ttl time.Duration logger *slog.Logger trustedBotLogins map[string]struct{} + + viewerMu sync.Mutex + viewerLogin string } type repoAccessCacheEntry struct { - isPrivate bool - knownUsers map[string]bool // normalized login -> has push access - viewerLogin string + isPrivate bool + knownUsers map[string]bool // normalized login -> has push access } // RepoAccessInfo captures repository metadata needed for lockdown decisions. type RepoAccessInfo struct { IsPrivate bool HasPushAccess bool - ViewerLogin string } const ( @@ -41,11 +46,6 @@ const ( defaultRepoAccessCacheKey = "repo-access-cache" ) -var ( - instance *RepoAccessCache - instanceMu sync.Mutex -) - // RepoAccessOption configures RepoAccessCache at construction time. type RepoAccessOption func(*RepoAccessCache) @@ -64,8 +64,8 @@ func WithLogger(logger *slog.Logger) RepoAccessOption { } } -// WithCacheName overrides the cache table name used for storing entries. This option is intended for tests -// that need isolated cache instances. +// WithCacheName overrides the cache table name used for storing entries. +// Use this to isolate cache entries between tenants or in tests. func WithCacheName(name string) RepoAccessOption { return func(c *RepoAccessCache) { if name != "" { @@ -74,36 +74,24 @@ func WithCacheName(name string) RepoAccessOption { } } -// GetInstance returns the singleton instance of RepoAccessCache. -// It initializes the instance on first call with the provided client and options. -// Subsequent calls ignore the client and options parameters and return the existing instance. -// This is the preferred way to access the cache in production code. -func GetInstance(client *githubv4.Client, opts ...RepoAccessOption) *RepoAccessCache { - instanceMu.Lock() - defer instanceMu.Unlock() - if instance == nil { - instance = &RepoAccessCache{ - client: client, - cache: cache2go.Cache(defaultRepoAccessCacheKey), - ttl: defaultRepoAccessTTL, - trustedBotLogins: map[string]struct{}{ - "copilot": {}, - }, - } - for _, opt := range opts { - if opt != nil { - opt(instance) - } +// NewRepoAccessCache creates a RepoAccessCache bound to the supplied clients. +func NewRepoAccessCache(client *githubv4.Client, restClient *github.Client, opts ...RepoAccessOption) *RepoAccessCache { + c := &RepoAccessCache{ + client: client, + restClient: restClient, + cache: cache2go.Cache(defaultRepoAccessCacheKey), + ttl: defaultRepoAccessTTL, + trustedBotLogins: map[string]struct{}{ + "copilot": {}, + "github-actions[bot]": {}, + }, + } + for _, opt := range opts { + if opt != nil { + opt(c) } } - return instance -} - -// SetLogger updates the logger used for cache diagnostics. -func (c *RepoAccessCache) SetLogger(logger *slog.Logger) { - c.mu.Lock() - c.logger = logger - c.mu.Unlock() + return c } // CacheStats summarizes cache activity counters. @@ -120,6 +108,14 @@ type CacheStats struct { // - the repository is private; // - the content was created by the viewer. func (c *RepoAccessCache) IsSafeContent(ctx context.Context, username, owner, repo string) (bool, error) { + if c == nil { + return false, fmt.Errorf("nil repo access cache") + } + + if c.isTrustedBot(username) { + return true, nil + } + repoInfo, err := c.getRepoAccessInfo(ctx, username, owner, repo) if err != nil { return false, err @@ -128,10 +124,55 @@ func (c *RepoAccessCache) IsSafeContent(ctx context.Context, username, owner, re c.logDebug(ctx, fmt.Sprintf("evaluated repo access for user %s to %s/%s for content filtering, result: hasPushAccess=%t, isPrivate=%t", username, owner, repo, repoInfo.HasPushAccess, repoInfo.IsPrivate)) - if c.isTrustedBot(username) || repoInfo.IsPrivate || repoInfo.ViewerLogin == strings.ToLower(username) { + if repoInfo.IsPrivate { + return true, nil + } + if repoInfo.HasPushAccess { return true, nil } - return repoInfo.HasPushAccess, nil + + viewerLogin, err := c.viewerLoginFor(ctx) + if err != nil { + return false, err + } + return viewerLogin == strings.ToLower(username), nil +} + +func (c *RepoAccessCache) viewerLoginFor(ctx context.Context) (string, error) { + c.viewerMu.Lock() + defer c.viewerMu.Unlock() + if c.viewerLogin != "" { + return c.viewerLogin, nil + } + if c.client == nil { + return "", fmt.Errorf("nil GraphQL client") + } + var query struct { + Viewer struct { + Login githubv4.String + } + } + if err := c.client.Query(ctx, &query, nil); err != nil { + return "", fmt.Errorf("failed to query viewer login: %w", err) + } + login := strings.ToLower(string(query.Viewer.Login)) + if login == "" { + return "", fmt.Errorf("viewer login returned empty") + } + c.viewerLogin = login + return c.viewerLogin, nil +} + +// setViewerLogin seeds the cached viewer login from a piggy-backed query response. +func (c *RepoAccessCache) setViewerLogin(login string) { + if login == "" { + return + } + c.viewerMu.Lock() + defer c.viewerMu.Unlock() + if c.viewerLogin == "" { + c.viewerLogin = strings.ToLower(login) + } } func (c *RepoAccessCache) getRepoAccessInfo(ctx context.Context, username, owner, repo string) (RepoAccessInfo, error) { @@ -141,66 +182,68 @@ func (c *RepoAccessCache) getRepoAccessInfo(ctx context.Context, username, owner key := cacheKey(owner, repo) userKey := strings.ToLower(username) - c.mu.Lock() - defer c.mu.Unlock() - // Try to get entry from cache - this will keep the item alive if it exists - cacheItem, err := c.cache.Value(key) - if err == nil { + // Entries are immutable once added: the cache table is shared across instances, + // so we publish a fresh entry with a cloned knownUsers map on every miss. + if cacheItem, err := c.cache.Value(key); err == nil { entry := cacheItem.Data().(*repoAccessCacheEntry) if cachedHasPush, known := entry.knownUsers[userKey]; known { c.logDebug(ctx, fmt.Sprintf("repo access cache hit for user %s to %s/%s", username, owner, repo)) return RepoAccessInfo{ IsPrivate: entry.isPrivate, HasPushAccess: cachedHasPush, - ViewerLogin: entry.viewerLogin, }, nil } - c.logDebug(ctx, "known users cache miss, fetching from graphql API") + c.logDebug(ctx, "known users cache miss, fetching permission") - info, queryErr := c.queryRepoAccessInfo(ctx, username, owner, repo) - if queryErr != nil { - return RepoAccessInfo{}, queryErr + hasPush, pushErr := c.checkPushAccess(ctx, username, owner, repo) + if pushErr != nil { + return RepoAccessInfo{}, pushErr } - entry.knownUsers[userKey] = info.HasPushAccess - entry.viewerLogin = info.ViewerLogin - entry.isPrivate = info.IsPrivate - c.cache.Add(key, c.ttl, entry) + users := make(map[string]bool, len(entry.knownUsers)+1) + maps.Copy(users, entry.knownUsers) + users[userKey] = hasPush + c.cache.Add(key, c.ttl, &repoAccessCacheEntry{ + isPrivate: entry.isPrivate, + knownUsers: users, + }) return RepoAccessInfo{ IsPrivate: entry.isPrivate, - HasPushAccess: entry.knownUsers[userKey], - ViewerLogin: entry.viewerLogin, + HasPushAccess: hasPush, }, nil } c.logDebug(ctx, fmt.Sprintf("repo access cache miss for user %s to %s/%s", username, owner, repo)) - info, queryErr := c.queryRepoAccessInfo(ctx, username, owner, repo) + isPrivate, viewerLogin, queryErr := c.queryRepoAccessInfo(ctx, owner, repo) if queryErr != nil { return RepoAccessInfo{}, queryErr } + c.setViewerLogin(viewerLogin) - // Create new entry - entry := &repoAccessCacheEntry{ - knownUsers: map[string]bool{userKey: info.HasPushAccess}, - isPrivate: info.IsPrivate, - viewerLogin: info.ViewerLogin, + hasPush, pushErr := c.checkPushAccess(ctx, username, owner, repo) + if pushErr != nil { + return RepoAccessInfo{}, pushErr } - c.cache.Add(key, c.ttl, entry) + + c.cache.Add(key, c.ttl, &repoAccessCacheEntry{ + knownUsers: map[string]bool{userKey: hasPush}, + isPrivate: isPrivate, + }) return RepoAccessInfo{ - IsPrivate: entry.isPrivate, - HasPushAccess: entry.knownUsers[userKey], - ViewerLogin: entry.viewerLogin, + IsPrivate: isPrivate, + HasPushAccess: hasPush, }, nil } -func (c *RepoAccessCache) queryRepoAccessInfo(ctx context.Context, username, owner, repo string) (RepoAccessInfo, error) { +// queryRepoAccessInfo fetches repository visibility and the viewer login in a single GraphQL round-trip. +func (c *RepoAccessCache) queryRepoAccessInfo(ctx context.Context, owner, repo string) (bool, string, error) { if c.client == nil { - return RepoAccessInfo{}, fmt.Errorf("nil GraphQL client") + return false, "", fmt.Errorf("nil GraphQL client") } var query struct { @@ -208,46 +251,39 @@ func (c *RepoAccessCache) queryRepoAccessInfo(ctx context.Context, username, own Login githubv4.String } Repository struct { - IsPrivate githubv4.Boolean - Collaborators struct { - Edges []struct { - Permission githubv4.String - Node struct { - Login githubv4.String - } - } - } `graphql:"collaborators(query: $username, first: 1)"` + IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $name)"` } variables := map[string]any{ - "owner": githubv4.String(owner), - "name": githubv4.String(repo), - "username": githubv4.String(username), + "owner": githubv4.String(owner), + "name": githubv4.String(repo), } if err := c.client.Query(ctx, &query, variables); err != nil { - return RepoAccessInfo{}, fmt.Errorf("failed to query repository access info: %w", err) + return false, "", fmt.Errorf("failed to query repository metadata: %w", err) } - hasPush := false - for _, edge := range query.Repository.Collaborators.Edges { - login := string(edge.Node.Login) - if strings.EqualFold(login, username) { - permission := string(edge.Permission) - hasPush = permission == "WRITE" || permission == "ADMIN" || permission == "MAINTAIN" - break - } + c.logDebug(ctx, fmt.Sprintf("queried repo access info for %s/%s: isPrivate=%t", owner, repo, bool(query.Repository.IsPrivate))) + + return bool(query.Repository.IsPrivate), string(query.Viewer.Login), nil +} + +// checkPushAccess checks if the user has push access to the repository via the REST permission endpoint. +func (c *RepoAccessCache) checkPushAccess(ctx context.Context, username, owner, repo string) (bool, error) { + if c.restClient == nil { + return false, fmt.Errorf("nil REST client") } - c.logDebug(ctx, fmt.Sprintf("queried repo access info for user %s to %s/%s: isPrivate=%t, hasPushAccess=%t, viewerLogin=%s", - username, owner, repo, bool(query.Repository.IsPrivate), hasPush, query.Viewer.Login)) + permLevel, _, err := c.restClient.Repositories.GetPermissionLevel(ctx, owner, repo, username) + if err != nil { + return false, fmt.Errorf("failed to get user permission level: %w", err) + } - return RepoAccessInfo{ - IsPrivate: bool(query.Repository.IsPrivate), - HasPushAccess: hasPush, - ViewerLogin: string(query.Viewer.Login), - }, nil + // REST API maps "maintain" to "write" (and "triage" to "read") + // https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user + permission := permLevel.GetPermission() + return permission == "admin" || permission == "write", nil } func (c *RepoAccessCache) log(ctx context.Context, level slog.Level, msg string, attrs ...slog.Attr) { diff --git a/pkg/lockdown/lockdown_test.go b/pkg/lockdown/lockdown_test.go index c1cf5e86b8..f16d6a062c 100644 --- a/pkg/lockdown/lockdown_test.go +++ b/pkg/lockdown/lockdown_test.go @@ -1,12 +1,16 @@ package lockdown import ( + "encoding/json" + "errors" "net/http" + "net/http/httptest" "sync" "testing" "time" "github.com/github/github-mcp-server/internal/githubv4mock" + gogithub "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/require" ) @@ -17,20 +21,18 @@ const ( testUser = "octocat" ) +type viewerLoginQuery struct { + Viewer struct { + Login githubv4.String + } +} + type repoAccessQuery struct { Viewer struct { Login githubv4.String } Repository struct { - IsPrivate githubv4.Boolean - Collaborators struct { - Edges []struct { - Permission githubv4.String - Node struct { - Login githubv4.String - } - } - } `graphql:"collaborators(query: $username, first: 1)"` + IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $name)"` } @@ -53,43 +55,59 @@ func (c *countingTransport) CallCount() int { return c.calls } -func newMockRepoAccessCache(t *testing.T, ttl time.Duration) (*RepoAccessCache, *countingTransport) { - t.Helper() - - var query repoAccessQuery - +func newMockGQLClient(viewerLogin string, isPrivate bool) (*githubv4.Client, *countingTransport) { variables := map[string]any{ - "owner": githubv4.String(testOwner), - "name": githubv4.String(testRepo), - "username": githubv4.String(testUser), + "owner": githubv4.String(testOwner), + "name": githubv4.String(testRepo), } - response := githubv4mock.DataResponse(map[string]any{ - "viewer": map[string]any{ - "login": testUser, - }, - "repository": map[string]any{ - "isPrivate": false, - "collaborators": map[string]any{ - "edges": []any{ - map[string]any{ - "permission": "WRITE", - "node": map[string]any{ - "login": testUser, - }, - }, - }, - }, - }, - }) - - httpClient := githubv4mock.NewMockedHTTPClient(githubv4mock.NewQueryMatcher(query, variables, response)) + httpClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + viewerLoginQuery{}, + nil, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{"login": viewerLogin}, + }), + ), + githubv4mock.NewQueryMatcher( + repoAccessQuery{}, + variables, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{"login": viewerLogin}, + "repository": map[string]any{"isPrivate": isPrivate}, + }), + ), + ) counting := &countingTransport{next: httpClient.Transport} httpClient.Transport = counting - gqlClient := githubv4.NewClient(httpClient) + return gqlClient, counting +} - return GetInstance(gqlClient, WithTTL(ttl)), counting +func newMockRESTServer(t *testing.T, permission string) *gogithub.Client { + t.Helper() + restServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := gogithub.RepositoryPermissionLevel{Permission: gogithub.Ptr(permission)} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + t.Cleanup(restServer.Close) + restClient, err := gogithub.NewClient(gogithub.WithEnterpriseURLs(restServer.URL+"/", restServer.URL+"/")) + require.NoError(t, err) + return restClient +} + +func newMockRepoAccessCache(t *testing.T, ttl time.Duration) (*RepoAccessCache, *countingTransport) { + t.Helper() + gqlClient, counting := newMockGQLClient(testUser, false) + restClient := newMockRESTServer(t, "write") + cache := NewRepoAccessCache( + gqlClient, + restClient, + WithTTL(ttl), + WithCacheName(t.Name()), + ) + return cache, counting } func TestRepoAccessCacheEvictsAfterTTL(t *testing.T) { @@ -98,7 +116,7 @@ func TestRepoAccessCacheEvictsAfterTTL(t *testing.T) { cache, transport := newMockRepoAccessCache(t, 5*time.Millisecond) info, err := cache.getRepoAccessInfo(ctx, testUser, testOwner, testRepo) require.NoError(t, err) - require.Equal(t, testUser, info.ViewerLogin) + require.False(t, info.IsPrivate) require.True(t, info.HasPushAccess) require.EqualValues(t, 1, transport.CallCount()) @@ -106,7 +124,95 @@ func TestRepoAccessCacheEvictsAfterTTL(t *testing.T) { info, err = cache.getRepoAccessInfo(ctx, testUser, testOwner, testRepo) require.NoError(t, err) - require.Equal(t, testUser, info.ViewerLogin) + require.False(t, info.IsPrivate) require.True(t, info.HasPushAccess) require.EqualValues(t, 2, transport.CallCount()) } + +func TestRepoAccessCacheIsolatesViewerPerInstance(t *testing.T) { + ctx := t.Context() + + cacheName := t.Name() + restClient := newMockRESTServer(t, "read") + + attackerGQL, _ := newMockGQLClient("attacker", false) + attackerCache := NewRepoAccessCache(attackerGQL, restClient, WithCacheName(cacheName)) + safe, err := attackerCache.IsSafeContent(ctx, "attacker", testOwner, testRepo) + require.NoError(t, err) + require.True(t, safe) + + victimGQL, _ := newMockGQLClient("victim", false) + victimCache := NewRepoAccessCache(victimGQL, restClient, WithCacheName(cacheName)) + safe, err = victimCache.IsSafeContent(ctx, "attacker", testOwner, testRepo) + require.NoError(t, err) + require.False(t, safe, "attacker-authored content must not be safe for the victim") + + safe, err = victimCache.IsSafeContent(ctx, "victim", testOwner, testRepo) + require.NoError(t, err) + require.True(t, safe) +} + +type flakyTransport struct { + mu sync.Mutex + failN int + calls int + next http.RoundTripper +} + +func (f *flakyTransport) RoundTrip(req *http.Request) (*http.Response, error) { + f.mu.Lock() + f.calls++ + shouldFail := f.calls <= f.failN + f.mu.Unlock() + if shouldFail { + return nil, errors.New("simulated transient failure") + } + return f.next.RoundTrip(req) +} + +func TestRepoAccessCacheRetriesViewerLoginAfterTransientError(t *testing.T) { + ctx := t.Context() + + httpClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + viewerLoginQuery{}, + nil, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{"login": testUser}, + }), + ), + ) + flaky := &flakyTransport{next: httpClient.Transport, failN: 1} + httpClient.Transport = flaky + gqlClient := githubv4.NewClient(httpClient) + + cache := NewRepoAccessCache(gqlClient, nil, WithCacheName(t.Name())) + + _, err := cache.viewerLoginFor(ctx) + require.Error(t, err, "first call should surface the transient failure") + + login, err := cache.viewerLoginFor(ctx) + require.NoError(t, err, "second call must retry, not return the cached error") + require.Equal(t, testUser, login) +} + +func TestRepoAccessCacheRejectsEmptyViewerLogin(t *testing.T) { + ctx := t.Context() + + httpClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + viewerLoginQuery{}, + nil, + githubv4mock.DataResponse(map[string]any{ + "viewer": map[string]any{"login": ""}, + }), + ), + ) + gqlClient := githubv4.NewClient(httpClient) + + cache := NewRepoAccessCache(gqlClient, nil, WithCacheName(t.Name())) + + _, err := cache.viewerLoginFor(ctx) + require.Error(t, err) + require.Contains(t, err.Error(), "empty") +} diff --git a/pkg/observability/metrics/metrics.go b/pkg/observability/metrics/metrics.go new file mode 100644 index 0000000000..5e861b3e05 --- /dev/null +++ b/pkg/observability/metrics/metrics.go @@ -0,0 +1,13 @@ +package metrics + +import "time" + +// Metrics is a backend-agnostic interface for emitting metrics. +// Implementations can route to DataDog, log to slog, or discard (noop). +type Metrics interface { + Increment(key string, tags map[string]string) + Counter(key string, tags map[string]string, value int64) + Distribution(key string, tags map[string]string, value float64) + DistributionMs(key string, tags map[string]string, value time.Duration) + WithTags(tags map[string]string) Metrics +} diff --git a/pkg/observability/metrics/noop_sink.go b/pkg/observability/metrics/noop_sink.go new file mode 100644 index 0000000000..4ce9e337d8 --- /dev/null +++ b/pkg/observability/metrics/noop_sink.go @@ -0,0 +1,19 @@ +package metrics + +import "time" + +// NoopMetrics is a no-op implementation of the Metrics interface. +type NoopMetrics struct{} + +var _ Metrics = (*NoopMetrics)(nil) + +// NewNoopMetrics returns a new NoopMetrics. +func NewNoopMetrics() *NoopMetrics { + return &NoopMetrics{} +} + +func (n *NoopMetrics) Increment(_ string, _ map[string]string) {} +func (n *NoopMetrics) Counter(_ string, _ map[string]string, _ int64) {} +func (n *NoopMetrics) Distribution(_ string, _ map[string]string, _ float64) {} +func (n *NoopMetrics) DistributionMs(_ string, _ map[string]string, _ time.Duration) {} +func (n *NoopMetrics) WithTags(_ map[string]string) Metrics { return n } diff --git a/pkg/observability/metrics/noop_sink_test.go b/pkg/observability/metrics/noop_sink_test.go new file mode 100644 index 0000000000..21d3dccd6c --- /dev/null +++ b/pkg/observability/metrics/noop_sink_test.go @@ -0,0 +1,42 @@ +package metrics + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNoopMetrics_ImplementsInterface(_ *testing.T) { + var _ Metrics = (*NoopMetrics)(nil) +} + +func TestNoopMetrics_NoPanics(t *testing.T) { + m := NewNoopMetrics() + + assert.NotPanics(t, func() { + m.Increment("key", map[string]string{"a": "b"}) + m.Counter("key", map[string]string{"a": "b"}, 1) + m.Distribution("key", map[string]string{"a": "b"}, 1.5) + m.DistributionMs("key", map[string]string{"a": "b"}, time.Second) + }) +} + +func TestNoopMetrics_NilTags(t *testing.T) { + m := NewNoopMetrics() + + assert.NotPanics(t, func() { + m.Increment("key", nil) + m.Counter("key", nil, 1) + m.Distribution("key", nil, 1.5) + m.DistributionMs("key", nil, time.Second) + }) +} + +func TestNoopMetrics_WithTags(t *testing.T) { + m := NewNoopMetrics() + tagged := m.WithTags(map[string]string{"env": "prod"}) + + assert.NotNil(t, tagged) + assert.Equal(t, m, tagged) +} diff --git a/pkg/observability/observability.go b/pkg/observability/observability.go new file mode 100644 index 0000000000..3741b05c75 --- /dev/null +++ b/pkg/observability/observability.go @@ -0,0 +1,46 @@ +package observability + +import ( + "context" + "errors" + "log/slog" + + "github.com/github/github-mcp-server/pkg/observability/metrics" +) + +// Exporters bundles observability primitives (logger + metrics) for dependency injection. +// The logger is Go's stdlib *slog.Logger — integrators provide their own slog.Handler. +type Exporters interface { + Logger() *slog.Logger + Metrics(context.Context) metrics.Metrics +} + +type exporters struct { + logger *slog.Logger + metrics metrics.Metrics +} + +// NewExporters creates an Exporters bundle. Pass a configured *slog.Logger +// (with whatever slog.Handler you need) and a Metrics implementation. +// Neither may be nil; use slog.New(slog.DiscardHandler) and metrics.NewNoopMetrics() +// if logging or metrics are unwanted. +func NewExporters(logger *slog.Logger, m metrics.Metrics) (Exporters, error) { + if logger == nil { + return nil, errors.New("logger must not be nil: use slog.New(slog.DiscardHandler) to discard logs") + } + if m == nil { + return nil, errors.New("metrics must not be nil: use metrics.NewNoopMetrics() to discard metrics") + } + return &exporters{ + logger: logger, + metrics: m, + }, nil +} + +func (e *exporters) Logger() *slog.Logger { + return e.logger +} + +func (e *exporters) Metrics(_ context.Context) metrics.Metrics { + return e.metrics +} diff --git a/pkg/observability/observability_test.go b/pkg/observability/observability_test.go new file mode 100644 index 0000000000..c8949fdbd4 --- /dev/null +++ b/pkg/observability/observability_test.go @@ -0,0 +1,46 @@ +package observability + +import ( + "context" + "log/slog" + "testing" + + "github.com/github/github-mcp-server/pkg/observability/metrics" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewExporters(t *testing.T) { + logger := slog.Default() + m := metrics.NewNoopMetrics() + exp, err := NewExporters(logger, m) + ctx := context.Background() + + require.NoError(t, err) + assert.NotNil(t, exp) + assert.Equal(t, logger, exp.Logger()) + assert.Equal(t, m, exp.Metrics(ctx)) +} + +func TestNewExporters_WithNilLogger(t *testing.T) { + _, err := NewExporters(nil, metrics.NewNoopMetrics()) + require.Error(t, err) + assert.Contains(t, err.Error(), "logger must not be nil") +} + +func TestNewExporters_WithNilMetrics(t *testing.T) { + _, err := NewExporters(slog.New(slog.DiscardHandler), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "metrics must not be nil") +} + +func TestNewExporters_WithDiscardLogger(t *testing.T) { + logger := slog.New(slog.DiscardHandler) + m := metrics.NewNoopMetrics() + exp, err := NewExporters(logger, m) + + require.NoError(t, err) + assert.NotNil(t, exp) + assert.Equal(t, logger, exp.Logger()) + assert.Equal(t, m, exp.Metrics(context.Background())) +} diff --git a/pkg/raw/raw.go b/pkg/raw/raw.go index df9cd0ad11..4f794ac1f6 100644 --- a/pkg/raw/raw.go +++ b/pkg/raw/raw.go @@ -6,7 +6,7 @@ import ( "net/http" "net/url" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" ) // GetRawClientFn is a function type that returns a RawClient instance. @@ -19,19 +19,19 @@ type Client struct { } // NewClient creates a new instance of the raw API Client with the provided GitHub client and provided URL. -func NewClient(client *gogithub.Client, rawURL *url.URL) *Client { - client = gogithub.NewClient(client.Client()) - client.BaseURL = rawURL - return &Client{client: client, url: rawURL} -} - -func (c *Client) newRequest(ctx context.Context, method string, urlStr string, body any, opts ...gogithub.RequestOption) (*http.Request, error) { - req, err := c.client.NewRequest(method, urlStr, body, opts...) +func NewClient(client *gogithub.Client, rawURL *url.URL) (*Client, error) { + newClient, err := gogithub.NewClient( + gogithub.WithHTTPClient(client.Client()), + gogithub.WithEnterpriseURLs(rawURL.String(), rawURL.String()), + ) if err != nil { return nil, err } - req = req.WithContext(ctx) - return req, nil + return &Client{client: newClient, url: rawURL}, nil +} + +func (c *Client) newRequest(ctx context.Context, method string, urlStr string, body any, opts ...gogithub.RequestOption) (*http.Request, error) { + return c.client.NewRequest(ctx, method, urlStr, body, opts...) } func (c *Client) refURL(owner, repo, ref, path string) string { diff --git a/pkg/raw/raw_test.go b/pkg/raw/raw_test.go index 6897f492f6..60137684d7 100644 --- a/pkg/raw/raw_test.go +++ b/pkg/raw/raw_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/require" ) @@ -108,8 +108,10 @@ func TestGetRawContent(t *testing.T) { body: tc.body, }, } - ghClient := github.NewClient(mockedClient) - client := NewClient(ghClient, base) + ghClient, err := github.NewClient(github.WithHTTPClient(mockedClient)) + require.NoError(t, err) + client, err := NewClient(ghClient, base) + require.NoError(t, err) resp, err := client.GetRawContent(context.Background(), tc.owner, tc.repo, tc.path, tc.opts) defer func() { _ = resp.Body.Close() @@ -133,8 +135,10 @@ func TestGetRawContent(t *testing.T) { func TestUrlFromOpts(t *testing.T) { base, _ := url.Parse("https://raw.example.com/") - ghClient := github.NewClient(nil) - client := NewClient(ghClient, base) + ghClient, err := github.NewClient(github.WithHTTPClient(&http.Client{})) + require.NoError(t, err) + client, err := NewClient(ghClient, base) + require.NoError(t, err) tests := []struct { name string diff --git a/pkg/toolvalidation/readonlyhint.go b/pkg/toolvalidation/readonlyhint.go new file mode 100644 index 0000000000..bcde92a5ec --- /dev/null +++ b/pkg/toolvalidation/readonlyhint.go @@ -0,0 +1,256 @@ +// Package toolvalidation provides source-level (AST) validators for MCP tool +// registrations. It is intended to be consumed from _test.go files in any +// package that registers mcp.Tool literals (including downstream repositories +// such as github-mcp-server-remote) so the same guardrails apply everywhere +// without duplicating the parsing logic. +package toolvalidation + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" +) + +// MCPImportPath is the canonical module path of the MCP go-sdk. Source files +// that import this path under any alias (including the default `mcp`) are +// candidates for tool-literal validation. +const MCPImportPath = "github.com/modelcontextprotocol/go-sdk/mcp" + +// ReadOnlyHintViolation describes a single mcp.Tool composite literal that +// failed the ReadOnlyHint check. +type ReadOnlyHintViolation struct { + // File is the path to the offending source file, made relative to the + // scan directory when possible. + File string + // Line is the 1-indexed line number of the offending literal. + Line int + // ToolName is the value of the Name field on the mcp.Tool literal, or + // "" when it cannot be statically extracted. + ToolName string + // Reason is a human-readable explanation of why the literal failed. + Reason string +} + +// String renders a violation in the format used by FormatReadOnlyHintViolations: +// ": tool=: ". +func (v ReadOnlyHintViolation) String() string { + return fmt.Sprintf("%s:%d tool=%s: %s", v.File, v.Line, v.ToolName, v.Reason) +} + +// ScanReadOnlyHint parses every non-test .go file in dir (a single package +// directory) and returns a violation for each mcp.Tool composite literal that +// does not explicitly set Annotations.ReadOnlyHint. +// +// The Go runtime cannot distinguish an unset bool field from one explicitly +// set to false, so this AST-level check exists to prevent future tool +// registrations from silently defaulting ReadOnlyHint to false — which has +// triggered downstream agents to prompt for human approval on safe read +// operations. +// +// Callers typically invoke this from a _test.go file: +// +// dir, _ := os.Getwd() +// violations, err := toolvalidation.ScanReadOnlyHint(dir) +func ScanReadOnlyHint(dir string) ([]ReadOnlyHintViolation, error) { + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, dir, func(info os.FileInfo) bool { + // Skip test files: they are allowed to construct mcp.Tool literals + // for fixtures or mocks where ReadOnlyHint is not meaningful. + return !strings.HasSuffix(info.Name(), "_test.go") + }, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parse package directory %q: %w", dir, err) + } + + var violations []ReadOnlyHintViolation + for _, pkg := range pkgs { + for filename, file := range pkg.Files { + aliases := mcpAliasesFor(file) + if len(aliases) == 0 { + continue + } + rel, relErr := filepath.Rel(dir, filename) + if relErr != nil || rel == "" { + rel = filepath.Base(filename) + } + ast.Inspect(file, func(n ast.Node) bool { + cl, ok := n.(*ast.CompositeLit) + if !ok { + return true + } + if !isQualifiedType(cl.Type, aliases, "Tool") { + return true + } + violations = append(violations, checkToolLiteral(cl, aliases, rel, fset.Position(cl.Pos()).Line)...) + return true + }) + } + } + return violations, nil +} + +// FormatReadOnlyHintViolations renders a single multi-line error message +// suitable for passing to t.Fatal. Returns "" when violations is empty. +func FormatReadOnlyHintViolations(violations []ReadOnlyHintViolation) string { + if len(violations) == 0 { + return "" + } + var msg strings.Builder + msg.WriteString("Found tool registrations that do not explicitly set ReadOnlyHint:\n") + for _, v := range violations { + msg.WriteString(" - ") + msg.WriteString(v.String()) + msg.WriteByte('\n') + } + msg.WriteString("\nEvery mcp.Tool registration must declare Annotations.ReadOnlyHint explicitly ") + msg.WriteString("(true for read-only tools, false for tools with side effects). ") + msg.WriteString("See pkg/toolvalidation.ScanReadOnlyHint.") + return msg.String() +} + +func checkToolLiteral(cl *ast.CompositeLit, aliases map[string]struct{}, file string, line int) []ReadOnlyHintViolation { + toolName := extractToolName(cl) + if toolName == "" { + toolName = "" + } + mk := func(reason string) ReadOnlyHintViolation { + return ReadOnlyHintViolation{File: file, Line: line, ToolName: toolName, Reason: reason} + } + + if hasUnkeyedFields(cl) { + return []ReadOnlyHintViolation{mk("mcp.Tool literal uses positional (unkeyed) fields; this check requires keyed fields so Annotations.ReadOnlyHint can be verified")} + } + + annotations := findFieldValue(cl, "Annotations") + if annotations == nil { + return []ReadOnlyHintViolation{mk("mcp.Tool literal is missing an Annotations field")} + } + + annoLit := unwrapAnnotationsLiteral(annotations, aliases) + if annoLit == nil { + return []ReadOnlyHintViolation{mk("Annotations is not an &mcp.ToolAnnotations{...} literal; ReadOnlyHint cannot be statically verified")} + } + + if hasUnkeyedFields(annoLit) { + return []ReadOnlyHintViolation{mk("mcp.ToolAnnotations literal uses positional (unkeyed) fields; use keyed fields so ReadOnlyHint can be verified")} + } + + if findFieldValue(annoLit, "ReadOnlyHint") == nil { + return []ReadOnlyHintViolation{mk("ToolAnnotations literal does not explicitly set ReadOnlyHint")} + } + return nil +} + +// mcpAliasesFor returns the set of local identifiers under which the given +// file imports the MCP go-sdk (MCPImportPath). The default unaliased import +// resolves to the package name "mcp". Blank (`_`) and dot (`.`) imports are +// skipped because tool literals cannot meaningfully be qualified through them. +func mcpAliasesFor(file *ast.File) map[string]struct{} { + aliases := map[string]struct{}{} + for _, imp := range file.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil || path != MCPImportPath { + continue + } + if imp.Name != nil { + if imp.Name.Name == "_" || imp.Name.Name == "." { + continue + } + aliases[imp.Name.Name] = struct{}{} + continue + } + aliases["mcp"] = struct{}{} + } + return aliases +} + +// isQualifiedType reports whether expr is a SelectorExpr of the form +// . where alias is in the provided alias set. +func isQualifiedType(expr ast.Expr, aliases map[string]struct{}, typeName string) bool { + sel, ok := expr.(*ast.SelectorExpr) + if !ok { + return false + } + ident, ok := sel.X.(*ast.Ident) + if !ok { + return false + } + if _, ok := aliases[ident.Name]; !ok { + return false + } + return sel.Sel != nil && sel.Sel.Name == typeName +} + +// hasUnkeyedFields reports whether the composite literal has any positional +// (non-key/value) elements. The static check cannot reliably map positional +// fields without full type information, so such literals are rejected with a +// dedicated diagnostic rather than producing false "missing field" violations. +func hasUnkeyedFields(cl *ast.CompositeLit) bool { + for _, elt := range cl.Elts { + if _, ok := elt.(*ast.KeyValueExpr); !ok { + return true + } + } + return false +} + +// findFieldValue returns the value expression for the named keyed field of a +// composite literal, or nil if the field is absent. +func findFieldValue(cl *ast.CompositeLit, name string) ast.Expr { + for _, elt := range cl.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + key, ok := kv.Key.(*ast.Ident) + if !ok { + continue + } + if key.Name == name { + return kv.Value + } + } + return nil +} + +// unwrapAnnotationsLiteral attempts to extract the *ast.CompositeLit for +// &mcp.ToolAnnotations{...} or mcp.ToolAnnotations{...} from an expression, +// resolving the MCP package's local alias per file. +func unwrapAnnotationsLiteral(expr ast.Expr, aliases map[string]struct{}) *ast.CompositeLit { + if u, ok := expr.(*ast.UnaryExpr); ok && u.Op == token.AND { + expr = u.X + } + cl, ok := expr.(*ast.CompositeLit) + if !ok { + return nil + } + if !isQualifiedType(cl.Type, aliases, "ToolAnnotations") { + return nil + } + return cl +} + +// extractToolName returns the literal value of the Name field of an mcp.Tool +// composite literal, or empty string if the value is not a basic string literal. +// Interpreted ("...") and raw (`...`) string literals are handled via +// strconv.Unquote so embedded escapes are decoded correctly; the raw +// literal value is returned as a best-effort fallback if unquoting fails. +func extractToolName(cl *ast.CompositeLit) string { + v := findFieldValue(cl, "Name") + if v == nil { + return "" + } + bl, ok := v.(*ast.BasicLit) + if !ok || bl.Kind != token.STRING { + return "" + } + if unq, err := strconv.Unquote(bl.Value); err == nil { + return unq + } + return bl.Value +} diff --git a/pkg/toolvalidation/readonlyhint_test.go b/pkg/toolvalidation/readonlyhint_test.go new file mode 100644 index 0000000000..7ef3c4829b --- /dev/null +++ b/pkg/toolvalidation/readonlyhint_test.go @@ -0,0 +1,176 @@ +package toolvalidation_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/github-mcp-server/pkg/toolvalidation" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writePackage writes a single Go source file into a fresh temp directory and +// returns that directory, suitable for passing to ScanReadOnlyHint. +func writePackage(t *testing.T, filename, source string) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, filename), []byte(source), 0o600)) + return dir +} + +func TestScanReadOnlyHint(t *testing.T) { + t.Parallel() + + const compliant = `package fixture + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +var Tool = mcp.Tool{ + Name: "compliant_tool", + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: true, + }, +} +` + + const missingHint = `package fixture + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +var Tool = mcp.Tool{ + Name: "missing_hint", + Annotations: &mcp.ToolAnnotations{ + Title: "no hint", + }, +} +` + + const missingAnnotations = `package fixture + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +var Tool = mcp.Tool{ + Name: "missing_annotations", +} +` + + const nonLiteralAnnotations = `package fixture + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +func annotations() *mcp.ToolAnnotations { return &mcp.ToolAnnotations{ReadOnlyHint: true} } + +var Tool = mcp.Tool{ + Name: "non_literal", + Annotations: annotations(), +} +` + + const unkeyedTool = `package fixture + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +var Tool = mcp.Tool{"unkeyed", "desc", nil, nil, nil, nil} +` + + const aliasedImport = `package fixture + +import sdk "github.com/modelcontextprotocol/go-sdk/mcp" + +var Tool = sdk.Tool{ + Name: "aliased", + Annotations: &sdk.ToolAnnotations{ + ReadOnlyHint: false, + }, +} +` + + const noMCPImport = `package fixture + +import "fmt" + +var _ = fmt.Sprintln("nothing to scan here") +` + + cases := []struct { + name string + source string + expectCount int + expectReason string + expectToolName string + }{ + {name: "compliant literal passes", source: compliant, expectCount: 0}, + {name: "aliased import is detected", source: aliasedImport, expectCount: 0}, + {name: "file without mcp import is skipped", source: noMCPImport, expectCount: 0}, + { + name: "missing ReadOnlyHint is flagged", + source: missingHint, + expectCount: 1, + expectReason: "does not explicitly set ReadOnlyHint", + expectToolName: "missing_hint", + }, + { + name: "missing Annotations is flagged", + source: missingAnnotations, + expectCount: 1, + expectReason: "missing an Annotations field", + expectToolName: "missing_annotations", + }, + { + name: "non-literal Annotations is flagged", + source: nonLiteralAnnotations, + expectCount: 1, + expectReason: "not an &mcp.ToolAnnotations{...} literal", + expectToolName: "non_literal", + }, + { + name: "positional Tool fields are flagged", + source: unkeyedTool, + expectCount: 1, + expectReason: "positional (unkeyed) fields", + expectToolName: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dir := writePackage(t, "fixture.go", tc.source) + violations, err := toolvalidation.ScanReadOnlyHint(dir) + require.NoError(t, err) + require.Len(t, violations, tc.expectCount) + if tc.expectCount == 0 { + return + } + v := violations[0] + assert.Equal(t, "fixture.go", v.File) + assert.Greater(t, v.Line, 0) + assert.Equal(t, tc.expectToolName, v.ToolName) + assert.Contains(t, v.Reason, tc.expectReason) + }) + } +} + +func TestFormatReadOnlyHintViolations(t *testing.T) { + t.Parallel() + + assert.Empty(t, toolvalidation.FormatReadOnlyHintViolations(nil)) + + msg := toolvalidation.FormatReadOnlyHintViolations([]toolvalidation.ReadOnlyHintViolation{{ + File: "issues.go", + Line: 42, + ToolName: "issue_read", + Reason: "ToolAnnotations literal does not explicitly set ReadOnlyHint", + }}) + assert.True(t, strings.HasPrefix(msg, "Found tool registrations that do not explicitly set ReadOnlyHint:")) + assert.Contains(t, msg, "issues.go:42 tool=issue_read") + assert.Contains(t, msg, "true for read-only tools, false for tools with side effects") +} + +func TestScanReadOnlyHint_ReturnsErrorForMissingDirectory(t *testing.T) { + t.Parallel() + _, err := toolvalidation.ScanReadOnlyHint(filepath.Join(t.TempDir(), "does-not-exist")) + require.Error(t, err) +} diff --git a/script/conformance-test b/script/conformance-test index 3ff0a55c27..549ced271f 100755 --- a/script/conformance-test +++ b/script/conformance-test @@ -68,12 +68,6 @@ LIST_TOOLS_MSG='{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' LIST_RESOURCES_MSG='{"jsonrpc":"2.0","id":3,"method":"resources/listTemplates","params":{}}' LIST_PROMPTS_MSG='{"jsonrpc":"2.0","id":4,"method":"prompts/list","params":{}}' -# Dynamic toolset management tool calls (for dynamic mode testing) -LIST_TOOLSETS_MSG='{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"list_available_toolsets","arguments":{}}}' -GET_TOOLSET_TOOLS_MSG='{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"get_toolset_tools","arguments":{"toolset":"repos"}}}' -ENABLE_TOOLSET_MSG='{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"enable_toolset","arguments":{"toolset":"repos"}}}' -LIST_TOOLSETS_AFTER_MSG='{"jsonrpc":"2.0","id":13,"method":"tools/call","params":{"name":"list_available_toolsets","arguments":{}}}' - # Function to normalize JSON for comparison # Sorts all arrays (including nested ones) and formats consistently # Also handles embedded JSON strings in "text" fields (from tool call responses) @@ -154,84 +148,18 @@ run_mcp_test() { echo "$duration" } -# Function to run MCP server with dynamic tool calls (for dynamic mode testing) -run_mcp_dynamic_test() { - local binary="$1" - local name="$2" - local flags="$3" - local output_prefix="$4" - - local start_time end_time duration - start_time=$(date +%s.%N) - - # Run the server with dynamic tool calls in sequence: - # 1. Initialize - # 2. List available toolsets (before enable) - # 3. Get tools for repos toolset - # 4. Enable repos toolset - # 5. List available toolsets (after enable - should show repos as enabled) - output=$( - ( - echo "$INIT_MSG" - echo "$INITIALIZED_MSG" - echo "$LIST_TOOLSETS_MSG" - sleep 0.1 - echo "$GET_TOOLSET_TOOLS_MSG" - sleep 0.1 - echo "$ENABLE_TOOLSET_MSG" - sleep 0.1 - echo "$LIST_TOOLSETS_AFTER_MSG" - sleep 0.3 - ) | GITHUB_PERSONAL_ACCESS_TOKEN=1 $binary stdio $flags 2>/dev/null - ) - - end_time=$(date +%s.%N) - duration=$(echo "$end_time - $start_time" | bc) - - # Parse and save each response by matching JSON-RPC id - echo "$output" | while IFS= read -r line; do - id=$(echo "$line" | jq -r '.id // empty' 2>/dev/null) - case "$id" in - 1) echo "$line" | jq -S '.' > "${output_prefix}_initialize.json" 2>/dev/null ;; - 10) echo "$line" | jq -S '.' > "${output_prefix}_list_toolsets_before.json" 2>/dev/null ;; - 11) echo "$line" | jq -S '.' > "${output_prefix}_get_toolset_tools.json" 2>/dev/null ;; - 12) echo "$line" | jq -S '.' > "${output_prefix}_enable_toolset.json" 2>/dev/null ;; - 13) echo "$line" | jq -S '.' > "${output_prefix}_list_toolsets_after.json" 2>/dev/null ;; - esac - done - - # Create empty files if not created - touch "${output_prefix}_initialize.json" "${output_prefix}_list_toolsets_before.json" \ - "${output_prefix}_get_toolset_tools.json" "${output_prefix}_enable_toolset.json" \ - "${output_prefix}_list_toolsets_after.json" - - # Normalize all JSON files - for endpoint in initialize list_toolsets_before get_toolset_tools enable_toolset list_toolsets_after; do - normalize_json "${output_prefix}_${endpoint}.json" - done - - echo "$duration" -} - -# Test configurations - array of "name|flags|type" -# type can be "standard" or "dynamic" (for dynamic tool call testing) +# Test configurations - array of "name|flags" declare -a TEST_CONFIGS=( - "default||standard" - "read-only|--read-only|standard" - "dynamic-toolsets|--dynamic-toolsets|standard" - "read-only+dynamic|--read-only --dynamic-toolsets|standard" - "toolsets-repos|--toolsets=repos|standard" - "toolsets-issues|--toolsets=issues|standard" - "toolsets-pull_requests|--toolsets=pull_requests|standard" - "toolsets-repos,issues|--toolsets=repos,issues|standard" - "toolsets-all|--toolsets=all|standard" - "tools-get_me|--tools=get_me|standard" - "tools-get_me,list_issues|--tools=get_me,list_issues|standard" - "toolsets-repos+read-only|--toolsets=repos --read-only|standard" - "toolsets-all+dynamic|--toolsets=all --dynamic-toolsets|standard" - "toolsets-repos+dynamic|--toolsets=repos --dynamic-toolsets|standard" - "toolsets-repos,issues+dynamic|--toolsets=repos,issues --dynamic-toolsets|standard" - "dynamic-tool-calls|--dynamic-toolsets|dynamic" + "default|" + "read-only|--read-only" + "toolsets-repos|--toolsets=repos" + "toolsets-issues|--toolsets=issues" + "toolsets-pull_requests|--toolsets=pull_requests" + "toolsets-repos,issues|--toolsets=repos,issues" + "toolsets-all|--toolsets=all" + "tools-get_me|--tools=get_me" + "tools-get_me,list_issues|--tools=get_me,list_issues" + "toolsets-repos+read-only|--toolsets=repos --read-only" ) # Summary arrays @@ -244,36 +172,24 @@ log "${YELLOW}Running conformance tests...${NC}" log "" for config in "${TEST_CONFIGS[@]}"; do - IFS='|' read -r test_name flags test_type <<< "$config" - + IFS='|' read -r test_name flags <<< "$config" + log "${BLUE}Test: ${test_name}${NC}" log " Flags: ${flags:-}" - log " Type: ${test_type}" # Create output directories mkdir -p "$REPORT_DIR/main/$test_name" mkdir -p "$REPORT_DIR/branch/$test_name" mkdir -p "$REPORT_DIR/diffs/$test_name" - if [ "$test_type" = "dynamic" ]; then - # Run dynamic tool call test - main_time=$(run_mcp_dynamic_test "$REPORT_DIR/main/github-mcp-server" "main" "$flags" "$REPORT_DIR/main/$test_name/output") - log " Main: ${main_time}s" - - branch_time=$(run_mcp_dynamic_test "$REPORT_DIR/branch/github-mcp-server" "branch" "$flags" "$REPORT_DIR/branch/$test_name/output") - log " Branch: ${branch_time}s" - - endpoints="initialize list_toolsets_before get_toolset_tools enable_toolset list_toolsets_after" - else - # Run standard test - main_time=$(run_mcp_test "$REPORT_DIR/main/github-mcp-server" "main" "$flags" "$REPORT_DIR/main/$test_name/output") - log " Main: ${main_time}s" - - branch_time=$(run_mcp_test "$REPORT_DIR/branch/github-mcp-server" "branch" "$flags" "$REPORT_DIR/branch/$test_name/output") - log " Branch: ${branch_time}s" - - endpoints="initialize tools resources prompts" - fi + # Run standard test + main_time=$(run_mcp_test "$REPORT_DIR/main/github-mcp-server" "main" "$flags" "$REPORT_DIR/main/$test_name/output") + log " Main: ${main_time}s" + + branch_time=$(run_mcp_test "$REPORT_DIR/branch/github-mcp-server" "branch" "$flags" "$REPORT_DIR/branch/$test_name/output") + log " Branch: ${branch_time}s" + + endpoints="initialize tools resources prompts" # Calculate time difference time_diff=$(echo "$branch_time - $main_time" | bc) @@ -393,7 +309,7 @@ for i in "${!TEST_NAMES[@]}"; do echo "" >> "$REPORT_FILE" # Check all possible endpoints - for endpoint in initialize tools resources prompts list_toolsets_before get_toolset_tools enable_toolset list_toolsets_after; do + for endpoint in initialize tools resources prompts; do diff_file="$REPORT_DIR/diffs/$name/${endpoint}.diff" if [ -f "$diff_file" ] && [ -s "$diff_file" ]; then echo "#### ${endpoint}" >> "$REPORT_FILE" diff --git a/script/get-me b/script/get-me index 954f57cec0..ffd24a357f 100755 --- a/script/get-me +++ b/script/get-me @@ -6,12 +6,12 @@ output=$( echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"get-me-script","version":"1.0.0"}}}' echo '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_me","arguments":{}}}' - sleep 1 - ) | go run cmd/github-mcp-server/main.go stdio 2>/dev/null | tail -1 + sleep 3 + ) | go run cmd/github-mcp-server/main.go stdio "$@" 2>/dev/null | grep '"id":2' ) if command -v jq &> /dev/null; then - echo "$output" | jq '.result.content[0].text | fromjson' + echo "$output" | jq '{_meta: .result._meta, content: (.result.content[0].text | fromjson)}' else echo "$output" fi diff --git a/script/print-mcp-diff-configs/main.go b/script/print-mcp-diff-configs/main.go new file mode 100644 index 0000000000..421c9fce41 --- /dev/null +++ b/script/print-mcp-diff-configs/main.go @@ -0,0 +1,217 @@ +// Command print-mcp-diff-configs emits the configuration matrix consumed by +// the mcp-server-diff GitHub Action. The matrix is composed of three parts: +// +// 1. Hand-curated baseline configs (default, read-only, common toolset combos) +// 2. Insiders configs (--insiders, --insiders --read-only) — meta flag that +// expands to the curated insiders feature set +// 3. One config per entry in github.AllowedFeatureFlags — automatically kept +// in sync with the Go source so any new user-controllable feature flag +// gets diffed without touching the workflow +// +// The same logical matrix is rendered for two transports, selected by +// -transport: +// +// stdio Default. Args are appended to the action's top-level +// +// start_command (one stdio process per config). +// +// http-headers streamable-http transport against a shared HTTP server. The +// +// server is started once with no extra flags and every config +// provides its settings via X-MCP-* request headers, mirroring +// how the remote server is invoked in production (server-side +// defaults + per-user header overrides). +// +// Usage: +// +// go run ./script/print-mcp-diff-configs +// go run ./script/print-mcp-diff-configs -transport http-headers +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "sort" + "strings" + + "github.com/github/github-mcp-server/pkg/github" + mcphdr "github.com/github/github-mcp-server/pkg/http/headers" +) + +type config struct { + Name string `json:"name"` + Args string `json:"args,omitempty"` + Transport string `json:"transport,omitempty"` + ServerURL string `json:"server_url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` +} + +// baseEntry describes one logical configuration in transport-agnostic form. +// settings are translated to either CLI flags or X-MCP-* headers depending on +// the target transport. +type baseEntry struct { + name string + settings settings +} + +type settings struct { + toolsets string // comma-separated, "" for defaults + tools string + excludeTools string + features string + readOnly bool + insiders bool + lockdown bool +} + +const httpServerURL = "http://localhost:8082/mcp" + +func main() { + transport := flag.String("transport", "stdio", "Transport to target: stdio or http-headers") + flag.Parse() + + entries := baseEntries() + + var out []config + switch *transport { + case "stdio": + for _, e := range entries { + out = append(out, config{Name: e.name, Args: e.settings.toArgs()}) + } + case "http-headers": + for _, e := range entries { + h := e.settings.toHeaders() + if h == nil { + h = map[string]string{} + } + // The action's top-level headers may be replaced (not merged) by + // per-config headers, so always include the bearer token here. + // The token must match a recognized GitHub prefix so the server's + // Authorization parser accepts it without contacting the API. + h[mcphdr.AuthorizationHeader] = "Bearer ghp_test" + out = append(out, config{ + Name: e.name, + Transport: "streamable-http", + ServerURL: httpServerURL, + Headers: h, + }) + } + default: + fmt.Fprintf(os.Stderr, "unknown transport %q (want stdio or http-headers)\n", *transport) + os.Exit(2) + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(out); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func baseEntries() []baseEntry { + entries := []baseEntry{ + {name: "default"}, + {name: "read-only", settings: settings{readOnly: true}}, + {name: "toolsets-repos", settings: settings{toolsets: "repos"}}, + {name: "toolsets-issues", settings: settings{toolsets: "issues"}}, + {name: "toolsets-context", settings: settings{toolsets: "context"}}, + {name: "toolsets-pull_requests", settings: settings{toolsets: "pull_requests"}}, + {name: "toolsets-repos,issues", settings: settings{toolsets: "repos,issues"}}, + {name: "toolsets-issues,context", settings: settings{toolsets: "issues,context"}}, + {name: "toolsets-all", settings: settings{toolsets: "all"}}, + {name: "tools-get_me", settings: settings{tools: "get_me"}}, + {name: "tools-get_me,list_issues", settings: settings{tools: "get_me,list_issues"}}, + {name: "toolsets-repos+read-only", settings: settings{toolsets: "repos", readOnly: true}}, + {name: "insiders", settings: settings{insiders: true}}, + {name: "insiders+read-only", settings: settings{insiders: true, readOnly: true}}, + // Combined entries: exercise multiple settings together so we catch + // regressions when several X-MCP-* headers (or CLI flags) are merged. + {name: "combined-toolsets+exclude+readonly", settings: settings{ + toolsets: "repos,issues", + excludeTools: "delete_file", + readOnly: true, + }}, + {name: "combined-insiders+toolsets+features", settings: settings{ + insiders: true, + toolsets: "repos", + features: firstFeatureFlag(), + }}, + } + + flags := append([]string(nil), github.AllowedFeatureFlags...) + sort.Strings(flags) + for _, f := range flags { + entries = append(entries, baseEntry{ + name: "feature-" + f, + settings: settings{features: f}, + }) + } + return entries +} + +func (s settings) toArgs() string { + var parts []string + if s.toolsets != "" { + parts = append(parts, "--toolsets="+s.toolsets) + } + if s.tools != "" { + parts = append(parts, "--tools="+s.tools) + } + if s.excludeTools != "" { + parts = append(parts, "--exclude-tools="+s.excludeTools) + } + if s.features != "" { + parts = append(parts, "--features="+s.features) + } + if s.readOnly { + parts = append(parts, "--read-only") + } + if s.insiders { + parts = append(parts, "--insiders") + } + if s.lockdown { + parts = append(parts, "--lockdown-mode") + } + return strings.Join(parts, " ") +} + +func (s settings) toHeaders() map[string]string { + h := map[string]string{} + if s.toolsets != "" { + h[mcphdr.MCPToolsetsHeader] = s.toolsets + } + if s.tools != "" { + h[mcphdr.MCPToolsHeader] = s.tools + } + if s.excludeTools != "" { + h[mcphdr.MCPExcludeToolsHeader] = s.excludeTools + } + if s.features != "" { + h[mcphdr.MCPFeaturesHeader] = s.features + } + if s.readOnly { + h[mcphdr.MCPReadOnlyHeader] = "true" + } + if s.insiders { + h[mcphdr.MCPInsidersHeader] = "true" + } + if s.lockdown { + h[mcphdr.MCPLockdownHeader] = "true" + } + if len(h) == 0 { + return nil + } + return h +} + +func firstFeatureFlag() string { + flags := append([]string(nil), github.AllowedFeatureFlags...) + if len(flags) == 0 { + return "" + } + sort.Strings(flags) + return flags[0] +} diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index b62febda3c..5f56c1c89b 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -15,26 +15,22 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) - [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-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.2.5/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.21.0/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.23.0/LICENSE)) + - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.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/v82/github](https://pkg.go.dev/github.com/google/go-github/v82/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v82.0.0/LICENSE)) + - [github.com/google/go-github/v87/github](https://pkg.go.dev/github.com/google/go-github/v87/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v87.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.2.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/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.3/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/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.4.0/v2/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/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) - [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/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/b17143f71798/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/b17143f71798/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.6.1/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.6.1/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)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) - [github.com/segmentio/asm](https://pkg.go.dev/github.com/segmentio/asm) ([MIT](https://github.com/segmentio/asm/blob/v1.1.3/LICENSE)) - - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.3/LICENSE)) + - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.4/LICENSE)) - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) @@ -46,10 +42,9 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - - [golang.org/x/exp/slices](https://pkg.go.dev/golang.org/x/exp/slices) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/054e65f0:LICENSE)) - [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](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.40.0:LICENSE)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE)) + - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.41.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)) - - [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/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 825c1ed6a3..7d8213d2f2 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -15,26 +15,22 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) - [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-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.2.5/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.21.0/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.23.0/LICENSE)) + - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.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/v82/github](https://pkg.go.dev/github.com/google/go-github/v82/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v82.0.0/LICENSE)) + - [github.com/google/go-github/v87/github](https://pkg.go.dev/github.com/google/go-github/v87/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v87.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.2.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/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.3/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/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.4.0/v2/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/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) - [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/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/b17143f71798/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/b17143f71798/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.6.1/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.6.1/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)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) - [github.com/segmentio/asm](https://pkg.go.dev/github.com/segmentio/asm) ([MIT](https://github.com/segmentio/asm/blob/v1.1.3/LICENSE)) - - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.3/LICENSE)) + - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.4/LICENSE)) - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) @@ -46,10 +42,9 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - - [golang.org/x/exp/slices](https://pkg.go.dev/golang.org/x/exp/slices) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/054e65f0:LICENSE)) - [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](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.40.0:LICENSE)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE)) + - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.41.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)) - - [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/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 d45aa33e07..3d0fd8f386 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -15,27 +15,23 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) - [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-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.2.5/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.21.0/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.23.0/LICENSE)) + - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.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/v82/github](https://pkg.go.dev/github.com/google/go-github/v82/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v82.0.0/LICENSE)) + - [github.com/google/go-github/v87/github](https://pkg.go.dev/github.com/google/go-github/v87/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v87.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.2.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/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.3/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/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/v2.4.0/v2/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/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) - [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/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/b17143f71798/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/b17143f71798/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.6.1/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.6.1/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)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) - [github.com/segmentio/asm](https://pkg.go.dev/github.com/segmentio/asm) ([MIT](https://github.com/segmentio/asm/blob/v1.1.3/LICENSE)) - - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.3/LICENSE)) + - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.4/LICENSE)) - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) @@ -47,10 +43,9 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - - [golang.org/x/exp/slices](https://pkg.go.dev/golang.org/x/exp/slices) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/054e65f0:LICENSE)) - [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](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.40.0:LICENSE)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE)) + - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.41.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)) - - [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party/github.com/josharian/intern/license.md b/third-party/github.com/github/github-mcp-server/LICENSE similarity index 96% rename from third-party/github.com/josharian/intern/license.md rename to third-party/github.com/github/github-mcp-server/LICENSE index 353d3055f0..9a9cc50d37 100644 --- a/third-party/github.com/josharian/intern/license.md +++ b/third-party/github.com/github/github-mcp-server/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Josh Bleecher Snyder +Copyright (c) 2025 GitHub 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/github.com/go-openapi/jsonpointer/LICENSE b/third-party/github.com/go-openapi/jsonpointer/LICENSE deleted file mode 100644 index d645695673..0000000000 --- a/third-party/github.com/go-openapi/jsonpointer/LICENSE +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/third-party/github.com/go-openapi/swag/LICENSE b/third-party/github.com/go-openapi/swag/LICENSE deleted file mode 100644 index d645695673..0000000000 --- a/third-party/github.com/go-openapi/swag/LICENSE +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/third-party/github.com/google/go-github/v82/github/LICENSE b/third-party/github.com/google/go-github/v87/github/LICENSE similarity index 100% rename from third-party/github.com/google/go-github/v82/github/LICENSE rename to third-party/github.com/google/go-github/v87/github/LICENSE diff --git a/third-party/github.com/mailru/easyjson/LICENSE b/third-party/github.com/mailru/easyjson/LICENSE deleted file mode 100644 index fbff658f70..0000000000 --- a/third-party/github.com/mailru/easyjson/LICENSE +++ /dev/null @@ -1,7 +0,0 @@ -Copyright (c) 2016 Mail.Ru Group - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third-party/golang.org/x/exp/slices/LICENSE b/third-party/golang.org/x/oauth2/LICENSE similarity index 100% rename from third-party/golang.org/x/exp/slices/LICENSE rename to third-party/golang.org/x/oauth2/LICENSE diff --git a/third-party/gopkg.in/yaml.v3/LICENSE b/third-party/gopkg.in/yaml.v3/LICENSE deleted file mode 100644 index 2683e4bb1f..0000000000 --- a/third-party/gopkg.in/yaml.v3/LICENSE +++ /dev/null @@ -1,50 +0,0 @@ - -This project is covered by two different licenses: MIT and Apache. - -#### MIT License #### - -The following files were ported to Go from C files of libyaml, and thus -are still covered by their original MIT license, with the additional -copyright staring in 2011 when the project was ported over: - - apic.go emitterc.go parserc.go readerc.go scannerc.go - writerc.go yamlh.go yamlprivateh.go - -Copyright (c) 2006-2010 Kirill Simonov -Copyright (c) 2006-2011 Kirill Simonov - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -### Apache License ### - -All the remaining project files are covered by the Apache license: - -Copyright (c) 2011-2019 Canonical Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/third-party/gopkg.in/yaml.v3/NOTICE b/third-party/gopkg.in/yaml.v3/NOTICE deleted file mode 100644 index 866d74a7ad..0000000000 --- a/third-party/gopkg.in/yaml.v3/NOTICE +++ /dev/null @@ -1,13 +0,0 @@ -Copyright 2011-2016 Canonical Ltd. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/ui/package-lock.json b/ui/package-lock.json index f5314fb086..13d78a25a8 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "@github/markdown-toolbar-element": "^2.2.3", - "@modelcontextprotocol/ext-apps": "^1.0.0", + "@modelcontextprotocol/ext-apps": "^1.7.2", "@primer/octicons-react": "^19.0.0", "@primer/react": "^36.0.0", "react": "^18.0.0", @@ -21,11 +21,13 @@ "@types/node": "^25.2.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", - "@vitejs/plugin-react": "^4.3.0", - "cross-env": "^7.0.3", + "@vitejs/plugin-react": "^6.0.2", "typescript": "^5.7.0", - "vite": "^6.0.0", - "vite-plugin-singlefile": "^2.0.0" + "vite": "^8.0.13", + "vite-plugin-singlefile": "^2.3.3" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@babel/code-frame": { @@ -33,6 +35,7 @@ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -47,6 +50,7 @@ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -56,6 +60,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -86,6 +91,7 @@ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", @@ -115,6 +121,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", @@ -131,6 +138,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -140,6 +148,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" @@ -153,6 +162,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", @@ -170,6 +180,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -179,6 +190,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -188,6 +200,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -197,6 +210,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -206,6 +220,7 @@ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" @@ -219,6 +234,7 @@ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.29.0" }, @@ -245,38 +261,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -291,6 +275,7 @@ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", @@ -305,6 +290,7 @@ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -323,6 +309,7 @@ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" @@ -331,6 +318,40 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emotion/is-prop-valid": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", @@ -362,448 +383,6 @@ "license": "MIT", "peer": true }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@github/combobox-nav": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.3.1.tgz", @@ -835,9 +414,9 @@ "license": "MIT" }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "peer": true, "engines": { @@ -852,6 +431,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" @@ -862,6 +442,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -872,6 +453,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.0.0" } @@ -880,13 +462,15 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -905,35 +489,21 @@ "license": "BSD-3-Clause" }, "node_modules/@modelcontextprotocol/ext-apps": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.0.1.tgz", - "integrity": "sha512-rAPzBbB5GNgYk216paQjGKUgbNXSy/yeR95c0ni6Y4uvhWI2AeF+ztEOqQFLBMQy/MPM+02pbVK1HaQmQjMwYQ==", - "hasInstallScript": true, + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.2.tgz", + "integrity": "sha512-OOWKDxdAjYDcgHkmzVzccyyag3FK+jBWPaWu4WvTxFsU4R/cgOX4eep66zPRA5n4v6WfxUNibPyvX4iJ7egYTg==", "license": "MIT", "workspaces": [ "examples/*" ], - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "^1.2.21", - "@oven/bun-darwin-x64": "^1.2.21", - "@oven/bun-darwin-x64-baseline": "^1.2.21", - "@oven/bun-linux-aarch64": "^1.2.21", - "@oven/bun-linux-aarch64-musl": "^1.2.21", - "@oven/bun-linux-x64": "^1.2.21", - "@oven/bun-linux-x64-baseline": "^1.2.21", - "@oven/bun-linux-x64-musl": "^1.2.21", - "@oven/bun-linux-x64-musl-baseline": "^1.2.21", - "@oven/bun-windows-x64": "^1.2.21", - "@oven/bun-windows-x64-baseline": "^1.2.21", - "@rollup/rollup-darwin-arm64": "^4.53.3", - "@rollup/rollup-darwin-x64": "^4.53.3", - "@rollup/rollup-linux-arm64-gnu": "^4.53.3", - "@rollup/rollup-linux-x64-gnu": "^4.53.3", - "@rollup/rollup-win32-arm64-msvc": "^4.53.3", - "@rollup/rollup-win32-x64-msvc": "^4.53.3" + "dependencies": { + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">=20" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.24.0", + "@modelcontextprotocol/sdk": "^1.29.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" @@ -948,194 +518,80 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@oddbird/popover-polyfill": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@oddbird/popover-polyfill/-/popover-polyfill-0.3.8.tgz", - "integrity": "sha512-+aK7EHL3VggfsWGVqUwvtli2+kP5OWyseAsrefhzR2XWoi2oALUCeoDn63i5WS3ZOmLiXHRNBwHPeta8w+aM1g==", - "license": "BSD-3-Clause" - }, - "node_modules/@oven/bun-darwin-aarch64": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.8.tgz", - "integrity": "sha512-hPERz4IgXCM6Y6GdEEsJAFceyJMt29f3HlFzsvE/k+TQjChRhar6S+JggL35b9VmFfsdxyCOOTPqgnSrdV0etA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.8.tgz", - "integrity": "sha512-SaWIxsRQYiT/eA60bqA4l8iNO7cJ6YD8ie82RerRp9voceBxPIZiwX4y20cTKy5qNaSGr9LxfYq7vDywTipiog==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64-baseline": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.8.tgz", - "integrity": "sha512-ArHVWpCRZI3vGLoN2/8ud8Kzqlgn1Gv+fNw+pMB9x18IzgAEhKxFxsWffnoaH21amam4tAOhpeewRIgdNtB0Cw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-linux-aarch64": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.8.tgz", - "integrity": "sha512-rq0nNckobtS+ONoB95/Frfqr8jCtmSjjjEZlN4oyUx0KEBV11Vj4v3cDVaWzuI34ryL8FCog3HaqjfKn8R82Tw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-aarch64-musl": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.8.tgz", - "integrity": "sha512-HvJmhrfipL7GtuqFz6xNpmf27NGcCOMwCalPjNR6fvkLpe8A7Z1+QbxKKjOglelmlmZc3Vi2TgDUtxSqfqOToQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.8.tgz", - "integrity": "sha512-YDgqVx1MI8E0oDbCEUSkAMBKKGnUKfaRtMdLh9Bjhu7JQacQ/ZCpxwi4HPf5Q0O1TbWRrdxGw2tA2Ytxkn7s1Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-baseline": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.8.tgz", - "integrity": "sha512-3IkS3TuVOzMqPW6Gg9/8FEoKF/rpKZ9DZUfNy9GQ54+k4PGcXpptU3+dy8D4iDFCt4qe6bvoiAOdM44OOsZ+Wg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-musl": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.8.tgz", - "integrity": "sha512-o7Jm5zL4aw9UBs3BcZLVbgGm2V4F10MzAQAV+ziKzoEfYmYtvDqRVxgKEq7BzUOVy4LgfrfwzEXw5gAQGRrhQQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } }, - "node_modules/@oven/bun-linux-x64-musl-baseline": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.8.tgz", - "integrity": "sha512-5g8XJwHhcTh8SGoKO7pR54ILYDbuFkGo+68DOMTiVB5eLxuLET+Or/camHgk4QWp3nUS5kNjip4G8BE8i0rHVQ==", - "cpu": [ - "x64" - ], + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } }, - "node_modules/@oven/bun-windows-x64": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.8.tgz", - "integrity": "sha512-UDI3rowMm/tI6DIynpE4XqrOhr+1Ztk1NG707Wxv2nygup+anTswgCwjfjgmIe78LdoRNFrux2GpeolhQGW6vQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "node_modules/@oddbird/popover-polyfill": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@oddbird/popover-polyfill/-/popover-polyfill-0.3.8.tgz", + "integrity": "sha512-+aK7EHL3VggfsWGVqUwvtli2+kP5OWyseAsrefhzR2XWoi2oALUCeoDn63i5WS3ZOmLiXHRNBwHPeta8w+aM1g==", + "license": "BSD-3-Clause" }, - "node_modules/@oven/bun-windows-x64-baseline": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.8.tgz", - "integrity": "sha512-K6qBUKAZLXsjAwFxGTG87dsWlDjyDl2fqjJr7+x7lmv2m+aSEzmLOK+Z5pSvGkpjBp3LXV35UUgj8G0UTd0pPg==", - "cpu": [ - "x64" - ], + "node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "funding": { + "url": "https://github.com/sponsors/Boshen" + } }, "node_modules/@primer/behaviors": { "version": "1.10.1", @@ -1889,102 +1345,359 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/@primer/react/node_modules/unist-util-position": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-4.0.4.tgz", - "integrity": "sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==", + "node_modules/@primer/react/node_modules/unist-util-position": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-4.0.4.tgz", + "integrity": "sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/unist-util-stringify-position": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", + "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/unist-util-visit": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-4.1.2.tgz", + "integrity": "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^5.0.0", + "unist-util-visit-parents": "^5.1.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/unist-util-visit-parents": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz", + "integrity": "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/vfile": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.7.tgz", + "integrity": "sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "is-buffer": "^2.0.0", + "unist-util-stringify-position": "^3.0.0", + "vfile-message": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/vfile-message": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-3.1.4.tgz", + "integrity": "sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@primer/react/node_modules/unist-util-stringify-position": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", - "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@primer/react/node_modules/unist-util-visit": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-4.1.2.tgz", - "integrity": "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0", - "unist-util-visit-parents": "^5.1.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@primer/react/node_modules/unist-util-visit-parents": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz", - "integrity": "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@primer/react/node_modules/vfile": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.7.tgz", - "integrity": "sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "is-buffer": "^2.0.0", - "unist-util-stringify-position": "^3.0.0", - "vfile-message": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@primer/react/node_modules/vfile-message": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-3.1.4.tgz", - "integrity": "sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-stringify-position": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ "arm" ], @@ -1993,12 +1706,13 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ "arm64" ], @@ -2007,38 +1721,43 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "cpu": [ "arm64" ], @@ -2047,12 +1766,13 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "cpu": [ "x64" ], @@ -2061,12 +1781,13 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "cpu": [ "arm" ], @@ -2075,12 +1796,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "cpu": [ "arm" ], @@ -2089,25 +1811,28 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "cpu": [ "arm64" ], @@ -2116,12 +1841,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", "cpu": [ "loong64" ], @@ -2130,12 +1856,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", "cpu": [ "loong64" ], @@ -2144,12 +1871,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", "cpu": [ "ppc64" ], @@ -2158,12 +1886,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", "cpu": [ "ppc64" ], @@ -2172,12 +1901,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "cpu": [ "riscv64" ], @@ -2186,12 +1916,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "cpu": [ "riscv64" ], @@ -2200,12 +1931,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "cpu": [ "s390x" ], @@ -2214,25 +1946,28 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", "cpu": [ "x64" ], @@ -2241,12 +1976,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", "cpu": [ "x64" ], @@ -2255,12 +1991,13 @@ "optional": true, "os": [ "openbsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "cpu": [ "arm64" ], @@ -2269,25 +2006,28 @@ "optional": true, "os": [ "openharmony" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "cpu": [ "ia32" ], @@ -2296,12 +2036,13 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "cpu": [ "x64" ], @@ -2310,20 +2051,29 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "peer": true + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" }, "node_modules/@styled-system/background": { "version": "5.1.2", @@ -2458,49 +2208,15 @@ "@styled-system/css": "^5.1.5" } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@babel/types": "^7.28.2" + "tslib": "^2.4.0" } }, "node_modules/@types/debug": { @@ -2633,24 +2349,29 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "@rolldown/pluginutils": "^1.0.0" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } } }, "node_modules/accepts": { @@ -2668,9 +2389,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "peer": true, "dependencies": { @@ -2734,6 +2455,7 @@ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "license": "Apache-2.0", + "peer": true, "bin": { "baseline-browser-mapping": "dist/cli.js" } @@ -2795,6 +2517,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2878,7 +2601,8 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "CC-BY-4.0" + "license": "CC-BY-4.0", + "peer": true }, "node_modules/ccount": { "version": "2.0.1", @@ -2956,9 +2680,9 @@ } }, "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", "peer": true, "engines": { @@ -2983,7 +2707,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cookie": { "version": "0.7.2", @@ -3023,30 +2748,12 @@ "url": "https://opencollective.com/express" } }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", + "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -3142,6 +2849,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -3190,7 +2907,8 @@ "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/encodeurl": { "version": "2.0.0", @@ -3235,53 +2953,12 @@ "node": ">= 0.4" } }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -3339,9 +3016,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", "license": "MIT", "peer": true, "engines": { @@ -3393,13 +3070,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "peer": true, "dependencies": { - "ip-address": "10.0.1" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -3425,9 +3102,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -3538,6 +3215,7 @@ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -3618,9 +3296,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", "peer": true, "dependencies": { @@ -3697,9 +3375,9 @@ "peer": true }, "node_modules/hono": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz", - "integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==", + "version": "4.12.19", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz", + "integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==", "license": "MIT", "peer": true, "engines": { @@ -3768,9 +3446,9 @@ "license": "MIT" }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "peer": true, "engines": { @@ -3887,12 +3565,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "license": "MIT", "peer": true, "funding": { @@ -3910,6 +3589,7 @@ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "license": "MIT", + "peer": true, "bin": { "jsesc": "bin/jsesc" }, @@ -3936,6 +3616,7 @@ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "license": "MIT", + "peer": true, "bin": { "json5": "lib/cli.js" }, @@ -3952,10 +3633,271 @@ "node": ">=6" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT", "peer": true }, @@ -3998,6 +3940,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "license": "ISC", + "peer": true, "dependencies": { "yallist": "^3.0.2" } @@ -5039,7 +4982,8 @@ "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/object-assign": { "version": "4.1.1", @@ -5126,14 +5070,15 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "peer": true, "funding": { @@ -5148,9 +5093,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -5170,9 +5115,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -5247,9 +5192,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "peer": true, "dependencies": { @@ -5362,16 +5307,6 @@ "react": ">=18" } }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -5448,12 +5383,48 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } + }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -5465,31 +5436,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" } }, @@ -5543,6 +5514,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -5613,6 +5585,7 @@ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", + "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -5625,6 +5598,7 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -5650,14 +5624,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "peer": true, "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -5833,14 +5807,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -5868,9 +5842,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -5923,19 +5897,45 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "peer": true, "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { @@ -6085,6 +6085,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -6153,24 +6154,23 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -6179,14 +6179,15 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -6195,13 +6196,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -6228,9 +6232,9 @@ } }, "node_modules/vite-plugin-singlefile": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.0.tgz", - "integrity": "sha512-DAcHzYypM0CasNLSz/WG0VdKOCxGHErfrjOoyIPiNxTPTGmO6rRD/te93n1YL/s+miXq66ipF1brMBikf99c6A==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz", + "integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==", "dev": true, "license": "MIT", "dependencies": { @@ -6240,32 +6244,19 @@ "node": ">18.0.0" }, "peerDependencies": { - "rollup": "^4.44.1", - "vite": "^5.4.11 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "rollup": "^4.59.0", + "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { - "picomatch": { + "rollup": { "optional": true } } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -6280,6 +6271,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", + "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -6301,12 +6293,13 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "peer": true, "funding": { @@ -6314,13 +6307,13 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "license": "ISC", "peer": true, "peerDependencies": { - "zod": "^3.25 || ^4" + "zod": "^3.25.28 || ^4" } }, "node_modules/zwitch": { diff --git a/ui/package.json b/ui/package.json index 6b26ca3161..b5bf095851 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,18 +4,18 @@ "private": true, "type": "module", "description": "MCP App UIs for github-mcp-server using Primer React", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "scripts": { - "build": "npm run build:get-me && npm run build:issue-write && npm run build:pr-write", - "build:get-me": "cross-env APP=get-me vite build", - "build:issue-write": "cross-env APP=issue-write vite build", - "build:pr-write": "cross-env APP=pr-write vite build", + "build": "node scripts/build.mjs", "dev": "npm run build", "typecheck": "tsc --noEmit", "clean": "rm -rf dist" }, "dependencies": { "@github/markdown-toolbar-element": "^2.2.3", - "@modelcontextprotocol/ext-apps": "^1.0.0", + "@modelcontextprotocol/ext-apps": "^1.7.2", "@primer/octicons-react": "^19.0.0", "@primer/react": "^36.0.0", "react": "^18.0.0", @@ -27,10 +27,9 @@ "@types/node": "^25.2.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", - "@vitejs/plugin-react": "^4.3.0", - "cross-env": "^7.0.3", + "@vitejs/plugin-react": "^6.0.2", "typescript": "^5.7.0", - "vite": "^6.0.0", - "vite-plugin-singlefile": "^2.0.0" + "vite": "^8.0.13", + "vite-plugin-singlefile": "^2.3.3" } } diff --git a/ui/scripts/build.mjs b/ui/scripts/build.mjs new file mode 100644 index 0000000000..c99d846039 --- /dev/null +++ b/ui/scripts/build.mjs @@ -0,0 +1,14 @@ +// Build all UI apps in a single Node process. +// +// Replaces three serial `cross-env APP= vite build` invocations: doing it +// in one process avoids paying Vite/plugin startup cost three times and is +// portable without `cross-env`. + +import { build } from "vite"; + +const apps = ["get-me", "issue-write", "pr-write"]; + +for (const app of apps) { + process.env.APP = app; + await build(); +} diff --git a/ui/src/apps/get-me/App.tsx b/ui/src/apps/get-me/App.tsx index a20aae17c5..c181fcab90 100644 --- a/ui/src/apps/get-me/App.tsx +++ b/ui/src/apps/get-me/App.tsx @@ -1,4 +1,5 @@ import { StrictMode, useState } from "react"; +import type React from "react"; import { createRoot } from "react-dom/client"; import { Avatar, Box, Text, Link, Heading, Spinner } from "@primer/react"; import { @@ -62,8 +63,20 @@ function AvatarWithFallback({ src, login, size }: { src?: string; login: string; ); } -function UserCard({ user }: { user: UserData }) { +function UserCard({ + user, + onOpenLink, +}: { + user: UserData; + onOpenLink?: (url: string) => void; +}) { const d = user.details || {}; + const handleClick = + onOpenLink && + ((url: string) => (e: React.MouseEvent) => { + e.preventDefault(); + onOpenLink(url); + }); return ( - {d.blog} + + {d.blog} + )} {d.email && ( @@ -140,41 +159,39 @@ function UserCard({ user }: { user: UserData }) { } function GetMeApp() { - const { error, toolResult } = useMcpApp({ + const { error, toolResult, hostContext, openLink } = useMcpApp({ appName: "github-mcp-server-get-me", }); - if (error) { - return Error: {error.message}; - } - - if (!toolResult) { - return ( - - - Loading user data... - - ); - } - - // Parse user data from tool result - const textContent = toolResult.content?.find((c: { type: string }) => c.type === "text"); - if (!textContent || !("text" in textContent)) { - return No user data in response; - } + const content = (() => { + if (error) { + return Error: {error.message}; + } + if (!toolResult) { + return ( + + + Loading user data... + + ); + } + const textContent = toolResult.content?.find((c: { type: string }) => c.type === "text"); + if (!textContent || !("text" in textContent)) { + return No user data in response; + } + try { + const userData = JSON.parse(textContent.text as string) as UserData; + return void openLink(url)} />; + } catch { + return Failed to parse user data; + } + })(); - try { - const userData = JSON.parse(textContent.text as string) as UserData; - return ; - } catch { - return Failed to parse user data; - } + return {content}; } createRoot(document.getElementById("root")!).render( - - - + ); diff --git a/ui/src/apps/issue-write/App.tsx b/ui/src/apps/issue-write/App.tsx index de72b0a78a..6c46b8c081 100644 --- a/ui/src/apps/issue-write/App.tsx +++ b/ui/src/apps/issue-write/App.tsx @@ -33,12 +33,14 @@ function SuccessView({ repo, submittedTitle, isUpdate, + openLink, }: { issue: IssueResult; owner: string; repo: string; submittedTitle: string; isUpdate: boolean; + openLink: (url: string) => Promise; }) { const issueUrl = issue.html_url || issue.url || issue.URL || "#"; @@ -87,6 +89,14 @@ function SuccessView({ href={issueUrl} target="_blank" rel="noopener noreferrer" + onClick={(e) => { + // MCP Apps run in a sandboxed iframe where a plain anchor may be + // blocked, so route the click through the host's open-link + // capability (falls back to window.open). + e.preventDefault(); + if (issueUrl === "#") return; + void openLink(issueUrl); + }} style={{ fontWeight: 600, fontSize: "14px", @@ -121,7 +131,7 @@ function CreateIssueApp() { const [error, setError] = useState(null); const [successIssue, setSuccessIssue] = useState(null); - const { app, error: appError, toolInput, callTool } = useMcpApp({ + const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({ appName: "github-mcp-server-issue-write", }); @@ -152,6 +162,7 @@ function CreateIssueApp() { try { const params: Record = { + ...(toolInput as Record | undefined), method: isUpdateMode ? "update" : "create", owner, repo, @@ -181,6 +192,19 @@ function CreateIssueApp() { try { const issueData = JSON.parse(textContent.text as string); setSuccessIssue(issueData); + // Per the MCP Apps 2026-01-26 spec, push the created/updated issue + // into the model's context so subsequent agent turns have it. + void setModelContext({ + structuredContent: issueData, + content: [ + { + type: "text", + text: isUpdateMode + ? `Issue #${issueNumber} in ${owner}/${repo} was updated by the user via the issue-write view.` + : `A new issue was created in ${owner}/${repo} by the user via the issue-write view.`, + }, + ], + }); } catch { setSuccessIssue({ title, body }); } @@ -191,8 +215,9 @@ function CreateIssueApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, isUpdateMode, issueNumber, callTool]); + }, [title, body, owner, repo, isUpdateMode, issueNumber, toolInput, callTool, setModelContext]); + const body_node = (() => { if (appError) { return ( @@ -217,6 +242,7 @@ function CreateIssueApp() { repo={repo} submittedTitle={title} isUpdate={isUpdateMode} + openLink={openLink} /> ); } @@ -307,12 +333,13 @@ function CreateIssueApp() { ); + })(); + + return {body_node}; } createRoot(document.getElementById("root")!).render( - - - + ); diff --git a/ui/src/apps/pr-write/App.tsx b/ui/src/apps/pr-write/App.tsx index f5ddbdf29d..245753a1bc 100644 --- a/ui/src/apps/pr-write/App.tsx +++ b/ui/src/apps/pr-write/App.tsx @@ -36,11 +36,13 @@ function SuccessView({ owner, repo, submittedTitle, + openLink, }: { pr: PRResult; owner: string; repo: string; submittedTitle: string; + openLink: (url: string) => Promise; }) { const prUrl = pr.html_url || pr.url || pr.URL || "#"; @@ -89,6 +91,14 @@ function SuccessView({ href={prUrl} target="_blank" rel="noopener noreferrer" + onClick={(e) => { + // MCP Apps run in a sandboxed iframe where a plain anchor may be + // blocked, so route the click through the host's open-link + // capability (falls back to window.open). + e.preventDefault(); + if (prUrl === "#") return; + void openLink(prUrl); + }} style={{ fontWeight: 600, fontSize: "14px", @@ -126,7 +136,7 @@ function CreatePRApp() { const [isDraft, setIsDraft] = useState(false); const [maintainerCanModify, setMaintainerCanModify] = useState(true); - const { app, error: appError, toolInput, callTool } = useMcpApp({ + const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({ appName: "github-mcp-server-create-pull-request", }); @@ -156,6 +166,7 @@ function CreatePRApp() { try { const result = await callTool("create_pull_request", { + ...(toolInput as Record | undefined), owner, repo, title: title.trim(), body: body.trim(), @@ -175,6 +186,17 @@ function CreatePRApp() { if (textContent && textContent.type === "text" && textContent.text) { const prData = JSON.parse(textContent.text); setSuccessPR(prData); + // Push the new PR into the model context so subsequent agent + // turns can reference it (MCP Apps 2026-01-26 ui/update-model-context). + void setModelContext({ + structuredContent: prData, + content: [ + { + type: "text", + text: `A new pull request was created in ${owner}/${repo} by the user via the create-pull-request view.`, + }, + ], + }); } } } catch (e) { @@ -182,19 +204,19 @@ function CreatePRApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, callTool]); + }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, toolInput, callTool, setModelContext]); if (successPR) { return ( - - + + ); } if (!app && !appError) { return ( - + @@ -204,14 +226,14 @@ function CreatePRApp() { if (appError) { return ( - + {appError.message} ); } return ( - + { - // Set up theme data attributes for proper Primer theming - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - const colorMode = prefersDark ? "dark" : "light"; + // Prefer the host-supplied theme; fall back to the OS preference. + const colorMode = + hostTheme === "light" || hostTheme === "dark" + ? hostTheme + : window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; document.body.setAttribute("data-color-mode", colorMode); document.body.setAttribute("data-light-theme", "light"); document.body.setAttribute("data-dark-theme", "dark"); - }, []); + }, [hostTheme]); + + // Project the host's standardized CSS variables onto the root so child + // components can consume them via `var(--color-...)`. We rely on Primer's + // own defaults when the host does not supply variables. + const styleVars = useMemo(() => { + if (!hostVariables) return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(hostVariables)) { + if (typeof value === "string") out[key] = value; + } + return out as CSSProperties; + }, [hostVariables]); + + const colorMode = + hostTheme === "light" || hostTheme === "dark" ? hostTheme : "auto"; return ( - + - {children} + + {children} + + ); diff --git a/ui/src/components/FeedbackFooter.tsx b/ui/src/components/FeedbackFooter.tsx new file mode 100644 index 0000000000..10fbdf44e6 --- /dev/null +++ b/ui/src/components/FeedbackFooter.tsx @@ -0,0 +1,17 @@ +import { Box, Text } from "@primer/react"; + +export function FeedbackFooter() { + return ( + + + Help us improve MCP Apps support in the GitHub MCP Server +
+ github.com/github/github-mcp-server/issues/new?template=insiders-feedback.md +
+
+ ); +} diff --git a/ui/src/hooks/useMcpApp.ts b/ui/src/hooks/useMcpApp.ts index 05798f5086..cf386520f0 100644 --- a/ui/src/hooks/useMcpApp.ts +++ b/ui/src/hooks/useMcpApp.ts @@ -1,11 +1,23 @@ import { useApp as useExtApp } from "@modelcontextprotocol/ext-apps/react"; -import type { App } from "@modelcontextprotocol/ext-apps"; +import type { + App, + McpUiDisplayMode, + McpUiHostContext, + McpUiUpdateModelContextRequest, +} from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; interface UseMcpAppOptions { appName: string; appVersion?: string; + /** + * Display modes this view supports. Per the MCP Apps 2026-01-26 spec, a + * view MUST declare every display mode it supports during initialization. + * Defaults to ["inline"] which is the only mode the bundled github-mcp-server + * views currently render. + */ + availableDisplayModes?: McpUiDisplayMode[]; onToolResult?: (result: CallToolResult) => void; onToolInput?: (input: Record) => void; } @@ -15,21 +27,40 @@ interface UseMcpAppReturn { error: Error | null; toolResult: CallToolResult | null; toolInput: Record | null; + hostContext: McpUiHostContext | undefined; callTool: (name: string, args: Record) => Promise; + /** + * Sends `ui/update-model-context` so the agent's next turn sees the + * supplied structured content / blocks. No-op when the app isn't connected. + */ + setModelContext: ( + params: McpUiUpdateModelContextRequest["params"] + ) => Promise; + /** + * Sends `ui/open-link` so the host opens an external URL in the user's + * browser. Falls back to `window.open` when the app isn't connected. + */ + openLink: (url: string) => Promise; } export function useMcpApp({ appName, appVersion = "1.0.0", + availableDisplayModes = ["inline"], onToolResult, onToolInput, }: UseMcpAppOptions): UseMcpAppReturn { const [toolResult, setToolResult] = useState(null); const [toolInput, setToolInput] = useState | null>(null); + const [hostContext, setHostContext] = useState(undefined); + // The SDK's autoResize=true installs a ResizeObserver that emits + // `ui/notifications/size-changed` automatically; no manual wiring needed. const { app, error } = useExtApp({ appInfo: { name: appName, version: appVersion }, - capabilities: {}, + capabilities: { availableDisplayModes }, + autoResize: true, + strict: import.meta.env.DEV, onAppCreated: (app) => { app.ontoolresult = async (result) => { setToolResult(result); @@ -40,10 +71,19 @@ export function useMcpApp({ setToolInput(args); onToolInput?.(args); }; + app.onhostcontextchanged = (params) => { + setHostContext((prev) => ({ ...(prev ?? {}), ...params })); + }; app.onerror = console.error; }, }); + useEffect(() => { + if (!app) return; + const initial = app.getHostContext(); + if (initial) setHostContext(initial); + }, [app]); + const callTool = useCallback( async (name: string, args: Record) => { if (!app) throw new Error("App not connected"); @@ -52,5 +92,38 @@ export function useMcpApp({ [app] ); - return { app, error, toolResult, toolInput, callTool }; + const setModelContext = useCallback( + async (params) => { + if (!app) return; + await app.updateModelContext(params); + }, + [app] + ); + + const openLink = useCallback( + async (url) => { + if (!app) { + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + const result = await app.openLink({ url }); + // The host may deny the request (e.g. blocked domain or user cancelled). + // Fall back to a direct window.open so the link still works. + if (result?.isError) { + window.open(url, "_blank", "noopener,noreferrer"); + } + }, + [app] + ); + + return { + app, + error, + toolResult, + toolInput, + hostContext, + callTool, + setModelContext, + openLink, + }; } diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 5b1777c706..963b39883d 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,39 +1,44 @@ import { defineConfig, Plugin } from "vite"; import react from "@vitejs/plugin-react"; import { viteSingleFile } from "vite-plugin-singlefile"; +import { existsSync, renameSync, rmSync } from "fs"; import { resolve } from "path"; -// Get the app to build from environment variable const app = process.env.APP; if (!app) { throw new Error("APP environment variable must be set"); } -// Plugin to rename the output file and remove the nested directory structure -function renameOutput(): Plugin { +const outDir = resolve(__dirname, "../pkg/github/ui_dist"); + +// vite-plugin-singlefile inlines all JS/CSS into the HTML, but Vite preserves +// the input file's relative path in the output (src/apps//index.html). +// After the bundle is written, hoist that file to /.html and +// remove the now-empty nested directories. Done in closeBundle (post-write) +// because Rolldown disallows mutating the in-memory bundle in generateBundle. +function flattenOutput(): Plugin { return { - name: "rename-output", + name: "flatten-output", enforce: "post", - generateBundle(_, bundle) { - // Find the HTML file and rename it - for (const fileName of Object.keys(bundle)) { - if (fileName.endsWith("index.html")) { - const chunk = bundle[fileName]; - chunk.fileName = `${app}.html`; - delete bundle[fileName]; - bundle[`${app}.html`] = chunk; - break; - } + closeBundle() { + const nested = resolve(outDir, `src/apps/${app}/index.html`); + const flat = resolve(outDir, `${app}.html`); + if (!existsSync(nested)) { + throw new Error( + `flatten-output: expected built HTML at ${nested} for app "${app}" but it was not emitted`, + ); } + renameSync(nested, flat); + rmSync(resolve(outDir, "src"), { recursive: true, force: true }); }, }; } export default defineConfig({ - plugins: [react(), viteSingleFile(), renameOutput()], + plugins: [react(), viteSingleFile(), flattenOutput()], build: { - outDir: resolve(__dirname, "../pkg/github/ui_dist"), + outDir, emptyOutDir: false, rollupOptions: { input: resolve(__dirname, `src/apps/${app}/index.html`),