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..46308ba0f8 --- /dev/null +++ b/.github/actions/build-ui/action.yml @@ -0,0 +1,39 @@ +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 + pkg/github/ui_dist/pr-edit.html + key: ui-dist-v2-${{ 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/prompts/bug-report-review.prompt.yml b/.github/prompts/bug-report-review.prompt.yml index 23c4bf70d2..ccb95eff0c 100644 --- a/.github/prompts/bug-report-review.prompt.yml +++ b/.github/prompts/bug-report-review.prompt.yml @@ -5,26 +5,38 @@ messages: Your job is to analyze bug reports and assess their completeness. + **CRITICAL: Detect unfilled templates** + - Flag issues containing unmodified template text like "A clear and concise description of what the bug is" + - Flag placeholder values like "Type this '...'" or "View the output '....'" that haven't been replaced + - Flag generic/meaningless titles (e.g., random words, test content) + - These are ALWAYS "Missing Details" even if the template structure is present + Analyze the issue for these key elements: - 1. Clear description of the problem + 1. Clear description of the problem (not template text) 2. Affected version (from running `docker run -i --rm ghcr.io/github/github-mcp-server ./github-mcp-server --version`) - 3. Steps to reproduce the behavior - 4. Expected vs actual behavior + 3. Steps to reproduce the behavior (actual steps, not placeholders) + 4. Expected vs actual behavior (real descriptions, not template text) 5. Relevant logs (if applicable) Provide ONE of these assessments: ### AI Assessment: Ready for Review - Use when the bug report has most required information and can be triaged by a maintainer. + Use when the bug report has actual information in required fields and can be triaged by a maintainer. ### AI Assessment: Missing Details - Use when critical information is missing (no reproduction steps, no version info, unclear problem description). + Use when: + - Template text has not been replaced with actual content + - Critical information is missing (no reproduction steps, no version info, unclear problem description) + - The title is meaningless or spam-like + - Placeholder text remains in any section + + When marking as Missing Details, recommend adding the "waiting-for-reply" label. ### AI Assessment: Unsure Use when you cannot determine the completeness of the report. After your assessment header, provide a brief explanation of your rating. - If details are missing, note which specific sections need more information. + If details are missing, be specific about which sections contain template text or need actual information. - role: user content: "{{input}}" model: openai/gpt-4o-mini diff --git a/.github/prompts/default-issue-review.prompt.yml b/.github/prompts/default-issue-review.prompt.yml index 6b4cd4a2bd..a574c9d89b 100644 --- a/.github/prompts/default-issue-review.prompt.yml +++ b/.github/prompts/default-issue-review.prompt.yml @@ -5,24 +5,47 @@ messages: Your job is to analyze new issues and help categorize them. + **CRITICAL: Detect invalid or incomplete submissions** + - Flag issues with unmodified template text (e.g., "A clear and concise description...") + - Flag placeholder values that haven't been replaced (e.g., "Type this '...'", "....", "XXX") + - Flag meaningless, spam-like, or test titles (e.g., random words, nonsensical content) + - Flag empty or nearly empty issues + - These are ALWAYS "Missing Details" or "Invalid" depending on severity + Analyze the issue to determine: - 1. Is this a bug report, feature request, question, or something else? - 2. Is the issue clear and well-described? + 1. Is this a bug report, feature request, question, documentation issue, or something else? + 2. Is the issue clear and well-described with actual content (not template text)? 3. Does it contain enough information for maintainers to act on? + 4. Is this potentially spam, a test issue, or completely invalid? Provide ONE of these assessments: ### AI Assessment: Ready for Review - Use when the issue is clear, well-described, and contains enough context for maintainers to understand and act on it. + Use when the issue is clear, well-described with actual content, and contains enough context for maintainers to understand and act on it. ### AI Assessment: Missing Details - Use when the issue is unclear, lacks context, or needs more information to be actionable. + Use when: + - Template text has not been replaced with actual content + - The issue is unclear or lacks context + - Critical information is missing to make it actionable + - The title is vague but the issue seems legitimate + + When marking as Missing Details, recommend adding the "waiting-for-reply" label. + + ### AI Assessment: Invalid + Use when: + - The issue appears to be spam or test content + - The title is completely meaningless and body has no useful information + - This doesn't relate to the GitHub MCP Server project at all + + When marking as Invalid, recommend adding the "invalid" label and consider closing. ### AI Assessment: Unsure Use when you cannot determine the nature or completeness of the issue. After your assessment header, provide a brief explanation including: - - What type of issue this appears to be (bug, feature request, question, etc.) + - What type of issue this appears to be (bug, feature request, question, invalid, etc.) + - Which specific sections contain template text or need actual information - What additional information might be helpful if any - role: user content: "{{input}}" diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml index 02c19fc77e..ecbe9f0dcb 100644 --- a/.github/workflows/code-scanning.yml +++ b/.github/workflows/code-scanning.yml @@ -35,6 +35,10 @@ jobs: category: /language:go build-mode: autobuild runner: '["ubuntu-22.04"]' + - language: javascript + category: /language:javascript + build-mode: none + runner: '["ubuntu-22.04"]' steps: - name: Checkout repository uses: actions/checkout@v6 @@ -74,6 +78,18 @@ jobs: go-version: ${{ fromJSON(steps.resolve-environment.outputs.environment).configuration.go.version }} cache: false + - name: Set up Node.js (for JavaScript CodeQL) + if: matrix.language == 'javascript' + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: ui/package-lock.json + + - name: Build UI + if: matrix.language == 'go' + 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 43eca9fad4..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@faadad0cce49287aee09b3a48701e75088a2c6ad #v4.0.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@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.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@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -93,15 +93,20 @@ jobs: key: ${{ runner.os }}-go-build-cache-${{ hashFiles('**/go.sum') }} - name: Inject go-build-cache - uses: reproducible-containers/buildkit-cache-dance@4b2444fec0c0fb9dbf175a96c094720a692ef810 # v2.1.4 + uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0 with: - cache-source: go-build-cache + cache-map: | + { + "go-build-cache/apk": "/var/cache/apk", + "go-build-cache/pkg": "/go/pkg/mod", + "go-build-cache/build": "/root/.cache/go-build" + } # Build and push Docker image with Buildx (don't push on PR) # https://github.com/docker/build-push-action - name: Build and push Docker image id: build-and-push - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.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 5084a78a1d..309eddb38e 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -16,6 +16,9 @@ jobs: - name: Checkout code uses: actions/checkout@v6 + - name: Build UI + uses: ./.github/actions/build-ui + - name: Set up Go uses: actions/setup-go@v6 with: diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 181a99560e..1fea50114a 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -25,6 +25,9 @@ jobs: - name: Check out code uses: actions/checkout@v6 + - name: Build UI + uses: ./.github/actions/build-ui + - name: Set up Go uses: actions/setup-go@v6 with: @@ -34,6 +37,7 @@ jobs: run: go mod tidy -diff - name: Run unit tests + shell: bash run: script/test - name: Build diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 167760cba8..1004fc2747 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -16,6 +16,9 @@ jobs: - name: Check out code uses: actions/checkout@v6 + - name: Build UI + uses: ./.github/actions/build-ui + - name: Set up Go uses: actions/setup-go@v6 with: @@ -25,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 @@ -37,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 9407732759..2f27353d83 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -32,6 +32,9 @@ jobs: GH_TOKEN: ${{ github.token }} run: gh pr checkout ${{ github.event.pull_request.number }} + - name: Build UI + uses: ./.github/actions/build-ui + - name: Set up Go uses: actions/setup-go@v6 with: @@ -67,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({ @@ -85,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 a1647446f4..5b912cea0f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,10 +14,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - name: Build UI + uses: ./.github/actions/build-ui - uses: actions/setup-go@v6 with: - go-version: stable + go-version: '1.25' - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.5 + # sync with script/lint + version: v2.9 diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml index ba9b59c6e1..62f08bacb0 100644 --- a/.github/workflows/mcp-diff.yml +++ b/.github/workflows/mcp-diff.yml @@ -19,44 +19,48 @@ jobs: 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 + # 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() @@ -70,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 eedf65165b..dc0a5f3a31 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,18 @@ bin/ .DS_Store # binary -github-mcp-server -mcpcurl -e2e.test +/github-mcp-server +/mcpcurl +/e2e.test .history conformance-report/ + +# UI build artifacts +ui/dist/ +ui/node_modules/ + +# Embedded UI assets (built from ui/) +pkg/github/ui_dist/* +!pkg/github/ui_dist/.gitkeep +!pkg/github/ui_dist/.placeholder.html \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index 6891db89e2..a32fc897e8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -9,12 +9,14 @@ linters: - gosec - makezero - misspell + - modernize - nakedret - revive - errcheck - staticcheck - govet - ineffassign + - intrange - unused exclusions: generated: lax @@ -27,6 +29,11 @@ linters: - third_party$ - builtin$ - examples$ + - internal/githubv4mock + rules: + - linters: + - revive + text: "var-naming: avoid package names that conflict with Go standard library package names" settings: staticcheck: checks: diff --git a/.vscode/launch.json b/.vscode/launch.json index cea7fd917d..0d90e162a6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,6 +23,16 @@ "program": "cmd/github-mcp-server/main.go", "args": ["stdio", "--read-only"], "console": "integratedTerminal", + }, + { + "name": "Launch http server", + "type": "go", + "request": "launch", + "mode": "auto", + "cwd": "${workspaceFolder}", + "program": "cmd/github-mcp-server/main.go", + "args": ["http", "--port", "8082"], + "console": "integratedTerminal", } ] } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f804c03aac..132752fde4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,13 @@ -FROM golang:1.25.6-alpine AS build +FROM node:26-alpine@sha256:3ad34ca6292aec4a91d8ddeb9229e29d9c2f689efd0dd242860889ac71842eba AS ui-build +WORKDIR /app +COPY ui/package*.json ./ui/ +RUN cd ui && npm ci +COPY ui/ ./ui/ +# Create output directory and build - vite outputs directly to pkg/github/ui_dist/ +RUN mkdir -p ./pkg/github/ui_dist && \ + cd ui && npm run build + +FROM golang:1.25.11-alpine@sha256:8d95af53d0d58e1759ddb4028285d9b1239067e4fbf4f544618cad0f60fbc354 AS build ARG VERSION="dev" # Set the working directory @@ -8,16 +17,20 @@ WORKDIR /build RUN --mount=type=cache,target=/var/cache/apk \ apk add git +# Copy source code (including ui_dist placeholder) +COPY . . + +# Copy built UI assets over the placeholder +COPY --from=ui-build /app/pkg/github/ui_dist/* ./pkg/github/ui_dist/ + # Build the server -# go build automatically download required module dependencies to /go/pkg/mod RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - --mount=type=bind,target=. \ CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ -o /bin/github-mcp-server ./cmd/github-mcp-server # Make a stage to run the app -FROM gcr.io/distroless/base-debian12 +FROM gcr.io/distroless/base-debian12@sha256:e7e678c88c59e70e105a46549bb3fbfb3d732ee3b4afd3a19fdab2e15afaa6b3 # Add required MCP server annotation LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server" @@ -26,6 +39,8 @@ LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server" WORKDIR /server # Copy the binary from the build stage COPY --from=build /bin/github-mcp-server . +# Expose the default port +EXPOSE 8082 # Set the entrypoint to the server binary ENTRYPOINT ["/server/github-mcp-server"] # Default arguments for ENTRYPOINT diff --git a/README.md b/README.md index afe003002e..404135aedc 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Built for developers who want to connect their AI tools to GitHub context and ca ## Remote GitHub MCP Server -[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](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) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](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&quality=insiders) +[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](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) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](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&quality=insiders) [![Install in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22github%22%2C%22gallery%22%3Atrue%2C%22url%22%3A%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) The remote GitHub MCP Server is hosted by GitHub and provides the easiest method for getting up and running. If your MCP host does not support remote MCP servers, don't worry! You can use the [local version of the GitHub MCP Server](https://github.com/github/github-mcp-server?tab=readme-ov-file#local-github-mcp-server) instead. @@ -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. @@ -140,7 +142,7 @@ When no toolsets are specified, [default toolsets](#default-toolset) are used. -See [Remote Server Documentation](docs/remote-server.md#insiders-mode) for more details and examples. +See [Remote Server Documentation](docs/remote-server.md#insiders-mode) for more details and examples, and [Insiders Features](docs/insiders-features.md) for a full list of what's available. #### GitHub Enterprise @@ -153,7 +155,7 @@ Example for `https://octocorp.ghe.com` with GitHub PAT token: ``` { ... - "proxima-github": { + "github-octocorp": { "type": "http", "url": "https://copilot-api.octocorp.ghe.com/mcp", "headers": { @@ -174,7 +176,7 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to ## Local GitHub MCP Server -[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D&quality=insiders) +[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D&quality=insiders) [![Install with Docker in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22github%22%2C%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%7D) ### Prerequisites @@ -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 @@ -559,7 +555,9 @@ The following sets of tools are available: | --- | ----------------------- | ------------------------------------------------------------- | | person | `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in | | workflow | `actions` | GitHub Actions workflows and CI/CD operations | +| code-square | `code_quality` | GitHub Code Quality related tools | | codescan | `code_security` | Code security related tools, such as GitHub Code Scanning | +| copilot | `copilot` | Copilot related tools | | dependabot | `dependabot` | Dependabot tools | | comment-discussion | `discussions` | GitHub Discussions related tools | | logo-gist | `gists` | GitHub Gist related tools | @@ -643,6 +641,18 @@ The following sets of tools are available:
+code-square Code Quality + +- **get_code_quality_finding** - Get code quality finding + - **Required OAuth Scopes**: `repo` + - `findingNumber`: The number of the finding. (number, required) + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + +
+ +
+ codescan Code Security - **get_code_scanning_alert** - Get code scanning alert @@ -656,6 +666,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) @@ -686,6 +698,26 @@ The following sets of tools are available:
+copilot Copilot + +- **assign_copilot_to_issue** - Assign Copilot to issue + - **Required OAuth Scopes**: `repo` + - `base_ref`: Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch (string, optional) + - `custom_instructions`: Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description (string, optional) + - `issue_number`: Issue number (number, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + +- **request_copilot_review** - Request Copilot review + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number (number, required) + - `repo`: Repository name (string, required) + +
+ +
+ dependabot Dependabot - **get_dependabot_alert** - Get dependabot alert @@ -698,7 +730,9 @@ The following sets of tools are available: - **list_dependabot_alerts** - List dependabot alerts - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `owner`: The owner of the repository. (string, required) + - `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) @@ -709,6 +743,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) @@ -717,8 +768,9 @@ The following sets of tools are available: - **get_discussion_comments** - Get discussion comments - **Required OAuth Scopes**: `repo` - - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) + - `after`: Cursor for pagination. Use the cursor from the previous response. (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) @@ -730,7 +782,7 @@ The following sets of tools are available: - **list_discussions** - List discussions - **Required OAuth Scopes**: `repo` - - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `category`: Optional filter by discussion category ID. If provided, only discussions with this category are listed. (string, optional) - `direction`: Order direction. (string, optional) - `orderBy`: Order discussions by field. If provided, the 'direction' also needs to be provided. (string, optional) @@ -787,22 +839,14 @@ The following sets of tools are available: issue-opened Issues -- **add_issue_comment** - Add comment to issue +- **add_issue_comment** - Add comment to issue or pull request - **Required OAuth Scopes**: `repo` - `body`: Comment content (string, required) - `issue_number`: Issue number to comment on (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) -- **assign_copilot_to_issue** - Assign Copilot to issue - - **Required OAuth Scopes**: `repo` - - `base_ref`: Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch (string, optional) - - `custom_instructions`: Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description (string, optional) - - `issue_number`: Issue number (number, required) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - -- **get_label** - Get a specific label from a repository. +- **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) @@ -823,7 +867,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/pull request - **Required OAuth Scopes**: `repo` - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) @@ -841,16 +885,17 @@ The following sets of tools are available: - `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) + - `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional) - **list_issue_types** - List available issue types - - **Required OAuth Scopes**: `read:org` - - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` - - `owner`: The organization owner of the repository (string, required) + - **Required OAuth Scopes (any of)**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `owner`: The account owner of the repository or organization. (string, required) + - `repo`: The name of the repository. When provided, returns issue types for this specific repository. When omitted, returns org-level issue types 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) + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - `labels`: Filter by labels (string[], optional) - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional) @@ -892,13 +937,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) @@ -983,9 +1028,10 @@ The following sets of tools are available: - `fields`: Specific list of field IDs to include in the response when getting a project item (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. Only used for 'get_project_item' method. (string[], optional) - `item_id`: The item's ID. Required for 'get_project_item' method. (number, optional) - `method`: The method to execute (string, required) - - `owner`: The owner (user or organization login). The name is not case sensitive. (string, required) + - `owner`: The owner (user or organization login). The name is not case sensitive. (string, optional) - `owner_type`: Owner type (user or org). If not provided, will be automatically detected. (string, optional) - - `project_number`: The project's number. (number, required) + - `project_number`: The project's number. (number, optional) + - `status_update_id`: The node ID of the project status update. Required for 'get_project_status_update' method. (string, optional) - **projects_list** - List GitHub Projects resources - **Required OAuth Scopes**: `read:project` @@ -997,21 +1043,29 @@ The following sets of tools are available: - `owner`: The owner (user or organization login). The name is not case sensitive. (string, required) - `owner_type`: Owner type (user or org). If not provided, will automatically try both. (string, optional) - `per_page`: Results per page (max 50) (number, optional) - - `project_number`: The project's number. Required for 'list_project_fields' and 'list_project_items' methods. (number, optional) + - `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`: 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)
@@ -1033,6 +1087,14 @@ The following sets of tools are available: - `startSide`: For multi-line comments, the starting side of the diff that the comment applies to. LEFT indicates the previous state, RIGHT indicates the new state (string, optional) - `subjectType`: The level at which the comment is targeted (string, required) +- **add_reply_to_pull_request_comment** - Add reply to pull request comment + - **Required OAuth Scopes**: `repo` + - `body`: The text of the reply (string, required) + - `commentId`: The ID of the comment to reply to (number, required) + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number (number, required) + - `repo`: Repository name (string, required) + - **create_pull_request** - Open new pull request - **Required OAuth Scopes**: `repo` - `base`: Branch to merge into (string, required) @@ -1042,6 +1104,7 @@ The following sets of tools are available: - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) - `title`: PR title (string, required) - **list_pull_requests** - List pull requests @@ -1067,15 +1130,18 @@ 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. 2. get_diff - Get the diff of a pull request. - 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks. + 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. - 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. + 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned. + 6. 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. + 7. 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. + 8. 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. + 9. 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) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) @@ -1083,7 +1149,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) @@ -1092,12 +1158,7 @@ The following sets of tools are available: - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) - -- **request_copilot_review** - Request Copilot review - - **Required OAuth Scopes**: `repo` - - `owner`: Repository owner (string, required) - - `pullNumber`: Pull request number (number, required) - - `repo`: Repository name (string, required) + - `threadId`: The node ID of the review thread (e.g., PRRT_kwDOxxx). Required for resolve_thread and unresolve_thread methods. Get thread IDs from pull_request_read with method get_review_comments. (string, optional) - **search_pull_requests** - Search pull requests - **Required OAuth Scopes**: `repo` @@ -1118,7 +1179,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) @@ -1150,7 +1211,7 @@ The following sets of tools are available: - `owner`: Repository owner (username or organization) (string, required) - `path`: Path where to create/update the file (string, required) - `repo`: Repository name (string, required) - - `sha`: The blob SHA of the file being replaced. (string, optional) + - `sha`: The blob SHA of the file being replaced. Required if the file already exists. (string, optional) - **create_repository** - Create repository - **Required OAuth Scopes**: `repo` @@ -1158,7 +1219,7 @@ The following sets of tools are available: - `description`: Repository description (string, optional) - `name`: Repository name (string, required) - `organization`: Organization to create the repository in (omit to create in your personal account) (string, optional) - - `private`: Whether repo should be private (boolean, optional) + - `private`: Whether the repository should be private. Defaults to true (private) when omitted. (boolean, optional) - **delete_file** - Delete file - **Required OAuth Scopes**: `repo` @@ -1176,7 +1237,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) @@ -1220,9 +1281,12 @@ The following sets of tools are available: - `author`: Author username or email address to filter commits by (string, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) + - `path`: Only commits containing this file path will be returned (string, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - `sha`: Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA. (string, optional) + - `since`: Only commits after this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD) (string, optional) + - `until`: Only commits before this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD) (string, optional) - **list_releases** - List releases - **Required OAuth Scopes**: `repo` @@ -1231,6 +1295,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) @@ -1251,9 +1323,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) @@ -1280,6 +1360,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) @@ -1388,6 +1470,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) @@ -1405,29 +1492,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. @@ -1515,6 +1579,34 @@ set the following environment variable: export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description" ``` +### Overriding Server Name and Title + +The same override mechanism can be used to customize the MCP server's `name` and +`title` fields in the initialization response. This is useful when running +multiple GitHub MCP Server instances (e.g., one for github.com and one for +GitHub Enterprise Server) so that agents can distinguish between them. + +| Key | Environment Variable | Default | +|-----|---------------------|---------| +| `SERVER_NAME` | `GITHUB_MCP_SERVER_NAME` | `github-mcp-server` | +| `SERVER_TITLE` | `GITHUB_MCP_SERVER_TITLE` | `GitHub MCP Server` | + +For example, to configure a server instance for GitHub Enterprise Server: + +```json +{ + "SERVER_NAME": "ghes-mcp-server", + "SERVER_TITLE": "GHES MCP Server" +} +``` + +Or using environment variables: + +```sh +export GITHUB_MCP_SERVER_NAME="ghes-mcp-server" +export GITHUB_MCP_SERVER_TITLE="GHES MCP Server" +``` + ## Library Usage The exported Go API of this module should currently be considered unstable, and subject to breaking changes. In the future, we may offer stability; please file an issue if there is a use case where this would be valuable. 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 78fd6c40a9..e8c044cde1 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "os" + "slices" "sort" "strings" @@ -28,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 @@ -36,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 { @@ -50,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) @@ -144,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) } @@ -154,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 "" } @@ -205,7 +221,15 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { // OAuth scopes if present if len(tool.RequiredScopes) > 0 { - fmt.Fprintf(buf, " - **Required OAuth Scopes**: `%s`\n", strings.Join(tool.RequiredScopes, "`, `")) + // Scope filtering uses "any of" semantics (see scopes.HasRequiredScopes), + // so when multiple required scopes are listed, render them as alternatives + // rather than implying all are required. + scopeList := "`" + strings.Join(tool.RequiredScopes, "`, `") + "`" + if len(tool.RequiredScopes) > 1 { + fmt.Fprintf(buf, " - **Required OAuth Scopes (any of)**: %s\n", scopeList) + } else { + fmt.Fprintf(buf, " - **Required OAuth Scopes**: %s\n", scopeList) + } // Only show accepted scopes if they differ from required scopes if len(tool.AcceptedScopes) > 0 && !scopesEqual(tool.RequiredScopes, tool.AcceptedScopes) { @@ -213,6 +237,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") @@ -232,9 +265,11 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { } sort.Strings(paramNames) + conditional := inventory.ConditionalSchemaPropertyDescriptions() + for i, propName := range paramNames { prop := schema.Properties[propName] - required := contains(schema.Required, propName) + required := slices.Contains(schema.Required, propName) requiredStr := "optional" if required { requiredStr = "required" @@ -257,7 +292,11 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { // Indent any continuation lines in the description to maintain markdown formatting description := indentMultilineDescription(prop.Description, " ") - fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr) + if cond, isConditional := conditional[propName]; isConditional { + fmt.Fprintf(buf, " - `%s`: %s (%s, %s, conditional — %s)", propName, description, typeStr, requiredStr, cond) + } else { + fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr) + } if i < len(paramNames)-1 { buf.WriteString("\n") } @@ -289,15 +328,6 @@ func scopesEqual(a, b []string) bool { return true } -func contains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false -} - // indentMultilineDescription adds the specified indent to all lines after the first line. // This ensures that multi-line descriptions maintain proper markdown list formatting. func indentMultilineDescription(description, indent string) string { @@ -319,14 +349,14 @@ func replaceSection(content, startMarker, endMarker, newContent string) (string, start := fmt.Sprintf("", startMarker) end := fmt.Sprintf("", endMarker) - startIdx := strings.Index(content, start) + before, _, ok := strings.Cut(content, start) endIdx := strings.Index(content, end) - if startIdx == -1 || endIdx == -1 { + if !ok || endIdx == -1 { return "", fmt.Errorf("markers not found: %s / %s", start, end) } var buf strings.Builder - buf.WriteString(content[:startIdx]) + buf.WriteString(before) buf.WriteString(start) buf.WriteString("\n") buf.WriteString(newContent) @@ -349,13 +379,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) @@ -426,6 +458,7 @@ func generateRemoteOnlyToolsetsDoc() string { return strings.TrimSuffix(buf.String(), "\n") } + func generateDeprecatedAliasesDocs(docsPath string) error { // Read the current file content, err := os.ReadFile(docsPath) //#nosec G304 diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index c361a4d5ab..604556692c 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -9,6 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/ghmcp" "github.com/github/github-mcp-server/pkg/github" + ghhttp "github.com/github/github-mcp-server/pkg/http" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -60,6 +61,14 @@ var ( } } + // Parse excluded tools (similar to tools) + 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) + } + } + // Parse enabled features (similar to toolsets) var enabledFeatures []string if viper.IsSet("features") { @@ -76,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"), @@ -84,11 +92,74 @@ var ( ContentWindowSize: viper.GetInt("content-window-size"), LockdownMode: viper.GetBool("lockdown-mode"), InsidersMode: viper.GetBool("insiders"), + ExcludeTools: excludeTools, RepoAccessCacheTTL: &ttl, } return ghmcp.RunStdioServer(stdioServerConfig) }, } + + httpCmd = &cobra.Command{ + Use: "http", + 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, + Host: viper.GetString("host"), + Port: viper.GetInt("port"), + ListenHost: viper.GetString("listen-host"), + BaseURL: viper.GetString("base-url"), + ResourcePath: viper.GetString("base-path"), + ExportTranslations: viper.GetBool("export-translations"), + EnableCommandLogging: viper.GetBool("enable-command-logging"), + LogFilePath: viper.GetString("log-file"), + ContentWindowSize: viper.GetInt("content-window-size"), + 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) + }, + } ) func init() { @@ -100,8 +171,8 @@ func init() { // Add global flags that will be shared by all commands rootCmd.PersistentFlags().StringSlice("toolsets", nil, github.GenerateToolsetsHelp()) 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") @@ -112,11 +183,19 @@ func init() { rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") + // HTTP-specific flags + httpCmd.Flags().Int("port", 8082, "HTTP server port") + httpCmd.Flags().String("listen-host", "", "Host the HTTP server binds to (e.g. 127.0.0.1). Empty binds to all interfaces.") + 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")) @@ -126,9 +205,15 @@ func init() { _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) _ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) - + _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) + _ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host")) + _ = 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) } func initConfig() { diff --git a/cmd/mcpcurl/main.go b/cmd/mcpcurl/main.go index 17b4bc77c4..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" @@ -73,8 +73,8 @@ type ( // RequestParams contains the tool name and arguments RequestParams struct { - Name string `json:"name"` - Arguments map[string]interface{} `json:"arguments"` + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` } // Content matches the response format of a text content response @@ -308,8 +308,8 @@ func addCommandFromTool(toolsCmd *cobra.Command, tool *Tool, prettyPrint bool) { } // buildArgumentsMap extracts flag values into a map of arguments -func buildArgumentsMap(cmd *cobra.Command, tool *Tool) (map[string]interface{}, error) { - arguments := make(map[string]interface{}) +func buildArgumentsMap(cmd *cobra.Command, tool *Tool) (map[string]any, error) { + arguments := make(map[string]any) for name, prop := range tool.InputSchema.Properties { switch prop.Type { @@ -340,7 +340,7 @@ func buildArgumentsMap(cmd *cobra.Command, tool *Tool) (map[string]interface{}, } case "object": if jsonStr, _ := cmd.Flags().GetString(name + "-json"); jsonStr != "" { - var jsonArray []interface{} + var jsonArray []any if err := json.Unmarshal([]byte(jsonStr), &jsonArray); err != nil { return nil, fmt.Errorf("error parsing JSON for %s: %w", name, err) } @@ -355,7 +355,7 @@ func buildArgumentsMap(cmd *cobra.Command, tool *Tool) (map[string]interface{}, } // buildJSONRPCRequest creates a JSON-RPC request with the given tool name and arguments -func buildJSONRPCRequest(method, toolName string, arguments map[string]interface{}) (string, error) { +func buildJSONRPCRequest(method, toolName string, arguments map[string]any) (string, error) { id, err := rand.Int(rand.Reader, big.NewInt(10000)) if err != nil { return "", fmt.Errorf("failed to generate random ID: %w", err) @@ -376,8 +376,8 @@ func buildJSONRPCRequest(method, toolName string, arguments map[string]interface 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 { @@ -432,7 +523,7 @@ func printResponse(response string, prettyPrint bool) error { // Extract text from content items of type "text" for _, content := range resp.Result.Content { if content.Type == "text" { - var textContentObj map[string]interface{} + var textContentObj map[string]any err := json.Unmarshal([]byte(content.Text), &textContentObj) if err == nil { @@ -445,7 +536,7 @@ func printResponse(response string, prettyPrint bool) error { } // Fallback parsing as JSONL - var textContentList []map[string]interface{} + var textContentList []map[string]any if err := json.Unmarshal([]byte(content.Text), &textContentList); err != nil { return fmt.Errorf("failed to parse text content as a list: %w", err) } 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..590cb65975 --- /dev/null +++ b/docs/feature-flags.md @@ -0,0 +1,327 @@ +# 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) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) + - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui) + - `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/pull request + - **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) + - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui) + - `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 issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + +- **ui_get** - Get UI data + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `method`: The type of data to fetch (string, required) + - `owner`: Repository owner (required for all methods) (string, required) + - `repo`: Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers) (string, optional) + +- **update_pull_request** - Edit pull request + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/pr-edit` + - `base`: New base branch name (string, optional) + - `body`: New description (string, optional) + - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) + - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number to update (number, required) + - `repo`: Repository name (string, required) + - `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) + +### `remote_mcp_issue_fields` + +- **issue_write** - Create or update issue/pull request + - **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 issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + +- **list_issue_fields** - List issue fields + - **Required OAuth Scopes (any of)**: `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 cursor from the previous response. (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) + +### `file_blame` + +- **get_file_blame** - Get file blame information + - **Required OAuth Scopes**: `repo` + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) + - `end_line`: Optional 1-based ending line of the window of interest. Must be >= start_line when both are provided. (number, optional) + - `owner`: Repository owner (username or organization) (string, required) + - `path`: Path to the file in the repository, relative to the repository root (string, required) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `ref`: Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (HEAD). (string, optional) + - `repo`: Repository name (string, required) + - `start_line`: Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window. (number, optional) + + diff --git a/docs/insiders-features.md b/docs/insiders-features.md new file mode 100644 index 0000000000..3306b5cd85 --- /dev/null +++ b/docs/insiders-features.md @@ -0,0 +1,229 @@ +# Insiders Features + +Insiders Mode gives you access to experimental features in the GitHub MCP Server. These features may change, evolve, or be removed based on community feedback. + +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! + +> [!NOTE] +> Features in Insiders Mode are experimental. + +## Enabling Insiders Mode + +| Method | Remote Server | Local Server | +|--------|---------------|--------------| +| URL path | Append `/insiders` to the URL | N/A | +| Header | `X-MCP-Insiders: true` | N/A | +| CLI flag | N/A | `--insiders` | +| Environment variable | N/A | `GITHUB_INSIDERS=true` | + +For configuration examples, see the [Server Configuration Guide](./server-configuration.md#insiders-mode). + +--- + +## 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) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) + - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui) + - `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/pull request + - **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) + - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui) + - `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 issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + +- **ui_get** - Get UI data + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `method`: The type of data to fetch (string, required) + - `owner`: Repository owner (required for all methods) (string, required) + - `repo`: Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers) (string, optional) + +- **update_pull_request** - Edit pull request + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/pr-edit` + - `base`: New base branch name (string, optional) + - `body`: New description (string, optional) + - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) + - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number to update (number, required) + - `repo`: Repository name (string, required) + - `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) + +### `remote_mcp_issue_fields` + +- **issue_write** - Create or update issue/pull request + - **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 issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + +- **list_issue_fields** - List issue fields + - **Required OAuth Scopes (any of)**: `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 cursor from the previous response. (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) + +### `file_blame` + +- **get_file_blame** - Get file blame information + - **Required OAuth Scopes**: `repo` + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) + - `end_line`: Optional 1-based ending line of the window of interest. Must be >= start_line when both are provided. (number, optional) + - `owner`: Repository owner (username or organization) (string, required) + - `path`: Path to the file in the repository, relative to the repository root (string, required) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `ref`: Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (HEAD). (string, optional) + - `repo`: Repository name (string, required) + - `start_line`: Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window. (number, 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. + +This means you can interact with GitHub visually: fill out forms to create issues, see user profiles with avatars, open pull requests — all without leaving your agent chat. + +### Supported tools + +The following tools have MCP Apps UIs: + +| 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 and working with: + +- **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 4a300e3f40..46581aa77e 100644 --- a/docs/installation-guides/README.md +++ b/docs/installation-guides/README.md @@ -6,11 +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 @@ -23,11 +28,17 @@ This directory contains detailed installation instructions for the GitHub MCP Se | Copilot in JetBrains | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: JetBrains Copilot Extension v1.5.53+ | Easy | | Claude Code | ✅ | ✅ PAT + ❌ No OAuth| GitHub MCP Server binary or remote URL, GitHub PAT | Easy | | Claude Desktop | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Moderate | +| 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 @@ -97,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-cline.md b/docs/installation-guides/install-cline.md new file mode 100644 index 0000000000..6bc643cb6a --- /dev/null +++ b/docs/installation-guides/install-cline.md @@ -0,0 +1,56 @@ +# Install GitHub MCP Server in Cline + +[Cline](https://github.com/cline/cline) is an AI coding assistant that runs in VS Code-compatible editors (VS Code, Cursor, Windsurf, etc.). For general setup information (prerequisites, Docker installation, security best practices), see the [Installation Guides README](./README.md). + +## Remote Server + +Cline stores MCP settings in `cline_mcp_settings.json`. To edit it, click the Cline icon in your editor's sidebar, open the menu in the top right corner of the Cline panel, and select **"MCP Servers"**. You can add a remote server through the **"Remote Servers"** tab, or click **"Configure MCP Servers"** to edit the JSON directly. + +```json +{ + "mcpServers": { + "github": { + "url": "https://api.githubcopilot.com/mcp/", + "type": "streamableHttp", + "disabled": false, + "headers": { + "Authorization": "Bearer " + }, + "autoApprove": [] + } + } +} +``` + +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 [Server Configuration Guide](../server-configuration.md). + +> **Important:** The transport type must be `"streamableHttp"` (camelCase, no hyphen). Using `"streamable-http"` or omitting the type will cause Cline to fall back to SSE, resulting in a `405` error. + +## Local Server (Docker) + +1. Click the Cline icon in your editor's sidebar (or open the command palette and search for "Cline"), then click the **MCP Servers** icon (server stack icon at the top of the Cline panel), and click **"Configure MCP Servers"** to open `cline_mcp_settings.json`. +2. Add the configuration below, replacing `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens). + +```json +{ + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT" + } + } + } +} +``` + +## Troubleshooting + +- **SSE error 405 with remote server**: Ensure `"type"` is set to `"streamableHttp"` (camelCase, no hyphen) in `cline_mcp_settings.json`. Using `"streamable-http"` or omitting `"type"` causes Cline to fall back to SSE, which this server does not support. +- **Authentication failures**: Verify your PAT has the required scopes +- **Docker issues**: Ensure Docker Desktop is installed and running 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-copilot-cli.md b/docs/installation-guides/install-copilot-cli.md index 5f95a03ef9..4ac5b3712c 100644 --- a/docs/installation-guides/install-copilot-cli.md +++ b/docs/installation-guides/install-copilot-cli.md @@ -1,10 +1,48 @@ # Install GitHub MCP Server in Copilot CLI -## Prerequisites +The GitHub MCP server comes pre-installed in Copilot CLI, with read-only tools enabled by default. -1. Copilot CLI installed (see [official Copilot CLI documentation](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli)) -2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes -3. For local installation: [Docker](https://www.docker.com/) installed and running +## Built-in Server + +To verify the server is available, from an active Copilot CLI session: + +```bash +/mcp show github-mcp-server +``` + +### Per-Session Customization + +Use CLI flags to customize the server for a session: + +```bash +# Enable an additional toolset +copilot --add-github-mcp-toolset discussions + +# Enable multiple additional toolsets +copilot --add-github-mcp-toolset discussions --add-github-mcp-toolset stargazers + +# Enable all toolsets +copilot --enable-all-github-mcp-tools + +# Enable a specific tool +copilot --add-github-mcp-tool list_discussions + +# Disable the built-in server entirely +copilot --disable-builtin-mcps +``` + +Run `copilot --help` for all available flags. For the list of toolsets, see [Available toolsets](../../README.md#available-toolsets); for the list of tools, see [Tools](../../README.md#tools). + +## Custom Configuration + +You can configure the GitHub MCP server in Copilot CLI using either the interactive command or by manually editing the configuration file. + +> **Server naming:** Name your server `github-mcp-server` to replace the built-in server, or use a different name (e.g., `github`) to run alongside it. + +### Prerequisites + +1. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes +2. For local server: [Docker](https://www.docker.com/) installed and running
Storing Your PAT Securely @@ -19,21 +57,17 @@ export GITHUB_PERSONAL_ACCESS_TOKEN=your_token_here
-## GitHub MCP Server Configuration - -You can configure the GitHub MCP server in Copilot CLI using either the interactive command or by manually editing the configuration file. - ### Method 1: Interactive Setup (Recommended) -Use the Copilot CLI to interactively add the MCP server: +From an active Copilot CLI session, run the interactive command: ```bash /mcp add ``` -Follow the prompts to configure the GitHub MCP server. +Follow the prompts to configure the server. -### Method 2: Manual Configuration +### Method 2: Manual Setup Create or edit the configuration file `~/.copilot/mcp-config.json` and add one of the following configurations: @@ -45,6 +79,7 @@ Connect to the hosted MCP server: { "mcpServers": { "github": { + "type": "http", "url": "https://api.githubcopilot.com/mcp/", "headers": { "Authorization": "Bearer ${GITHUB_PERSONAL_ACCESS_TOKEN}" @@ -54,6 +89,8 @@ Connect to the hosted MCP server: } ``` +For additional options like toolsets and read-only mode, see the [remote server documentation](../remote-server.md#optional-headers). + #### Local Docker With Docker running, you can run the GitHub MCP server in a container: @@ -81,9 +118,13 @@ With Docker running, you can run the GitHub MCP server in a container: #### Binary -You can download the latest binary release from the [GitHub releases page](https://github.com/github/github-mcp-server/releases) or build it from source by running `go build -o github-mcp-server ./cmd/github-mcp-server`. +You can download the latest binary release from the [GitHub releases page](https://github.com/github/github-mcp-server/releases) or build it from source by running: -Then, replacing `/path/to/binary` with the actual path to your binary, configure Copilot CLI with: +```bash +go build -o github-mcp-server ./cmd/github-mcp-server +``` + +Then configure (replace `/path/to/binary` with the actual path): ```json { @@ -101,35 +142,30 @@ Then, replacing `/path/to/binary` with the actual path to your binary, configure ## Verification -To verify that the GitHub MCP server has been configured: - -1. Start or restart Copilot CLI -2. The GitHub tools should be available for use in your conversations +1. Restart Copilot CLI +2. Run `/mcp show` to list configured servers +3. Try: "List my GitHub repositories" ## Troubleshooting ### Local Server Issues - **Docker errors**: Ensure Docker Desktop is running - ```bash - docker --version - ``` - **Image pull failures**: Try `docker logout ghcr.io` then retry -- **Docker not found**: Install Docker Desktop and ensure it's running ### Authentication Issues - **Invalid PAT**: Verify your GitHub PAT has correct scopes: - - `repo` - Repository operations - - `read:packages` - Docker image access (if using Docker) + - `repo` - Repository operations + - `read:packages` - Docker image access (if using Docker) - **Token expired**: Generate a new GitHub PAT ### Configuration Issues - **Invalid JSON**: Validate your configuration: - ```bash - cat ~/.copilot/mcp-config.json | jq . - ``` + ```bash + cat ~/.copilot/mcp-config.json | jq . + ``` ## References 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-roo-code.md b/docs/installation-guides/install-roo-code.md new file mode 100644 index 0000000000..77513fb555 --- /dev/null +++ b/docs/installation-guides/install-roo-code.md @@ -0,0 +1,58 @@ +# Install GitHub MCP Server in Roo Code + +[Roo Code](https://github.com/RooCodeInc/Roo-Code) is an AI coding assistant that runs in VS Code-compatible editors (VS Code, Cursor, Windsurf, etc.). For general setup information (prerequisites, Docker installation, security best practices), see the [Installation Guides README](./README.md). + +## Remote Server + +### Step-by-step setup + +1. Click the **Roo Code icon** in your editor's sidebar to open the Roo Code pane +2. Click the **gear icon** (⚙️) in the top navigation of the Roo Code pane, then click on **"MCP Servers"** icon on the left. +3. Scroll to the bottom and click **"Edit Global MCP"** (for all projects) or **"Edit Project MCP"** (for the current project only) +4. Add the configuration below to the opened file (`mcp_settings.json` or `.roo/mcp.json`) +5. Replace `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens) +6. Save the file — the server should connect automatically + +```json +{ + "mcpServers": { + "github": { + "type": "streamable-http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer YOUR_GITHUB_PAT" + } + } + } +} +``` + +> **Important:** The `type` must be `"streamable-http"` (with hyphen). Using `"http"` or omitting the type will fail. + +To customize toolsets, add server-side headers like `X-MCP-Toolsets` or `X-MCP-Readonly` to the `headers` object — see [Server Configuration Guide](../server-configuration.md). + +## Local Server (Docker) + +```json +{ + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT" + } + } + } +} +``` + +## Troubleshooting + +- **Connection failures**: Ensure `type` is `streamable-http`, not `http` +- **Authentication failures**: Verify PAT is prefixed with `Bearer ` in the `Authorization` header +- **Docker issues**: Ensure Docker Desktop is running 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 1496673936..4665ba8044 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,9 +19,12 @@ 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) | +| code-square
`code_quality` | GitHub Code Quality related tools | https://api.githubcopilot.com/mcp/x/code_quality | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_quality&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_quality%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_quality/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_quality&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_quality%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) | | dependabot
`dependabot` | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | | comment-discussion
`discussions` | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | | logo-gist
`gists` | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | @@ -46,7 +49,6 @@ These toolsets are only available in the remote GitHub MCP Server and are not in | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | | ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- | -| copilot
`copilot` | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | | copilot
`copilot_spaces` | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | | book
`github_support_docs_search` | Retrieve documentation to answer GitHub product and support questions. Topics include: GitHub Actions Workflows, Authentication, ... | https://api.githubcopilot.com/mcp/x/github_support_docs_search | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/github_support_docs_search/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%2Freadonly%22%7D) | @@ -121,13 +123,15 @@ The Remote GitHub MCP server supports the following URL path patterns: - `/` - Default toolset (see ["default" toolset](../README.md#default-toolset)) - `/readonly` - Default toolset in read-only mode - `/insiders` - Default toolset with insiders mode enabled -- `/insiders/readonly` - Default toolset with insiders mode in read-only mode +- `/readonly/insiders` - Default toolset in read-only mode with insiders mode enabled - `/x/all` - All available toolsets - `/x/all/readonly` - All available toolsets in read-only mode - `/x/all/insiders` - All available toolsets with insiders mode enabled +- `/x/all/readonly/insiders` - All available toolsets in read-only mode with insiders mode enabled - `/x/{toolset}` - Single specific toolset - `/x/{toolset}/readonly` - Single specific toolset in read-only mode - `/x/{toolset}/insiders` - Single specific toolset with insiders mode enabled +- `/x/{toolset}/readonly/insiders` - Single specific toolset in read-only mode with insiders mode enabled Note: `{toolset}` can only be a single toolset, not a comma-separated list. To combine multiple toolsets, use the `X-MCP-Toolsets` header instead. Path modifiers like `/readonly` and `/insiders` can be combined with the `X-MCP-Insiders` or `X-MCP-Readonly` headers. diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 46ec3bc64e..2342664c3a 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -9,10 +9,13 @@ We currently support the following ways in which the GitHub MCP Server can be co |---------------|---------------|--------------| | Toolsets | `X-MCP-Toolsets` header or `/x/{toolset}` URL | `--toolsets` flag or `GITHUB_TOOLSETS` env var | | 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` | > **Default behavior:** If you don't specify any configuration, the server uses the **default toolsets**: `context`, `issues`, `pull_requests`, `repos`, `users`. @@ -20,10 +23,12 @@ 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, 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. +Note: **excluded tools** takes precedence over toolsets and individual tools — listed tools are always excluded, even if their toolset is enabled or they are explicitly added via `--tools` / `X-MCP-Tools`. + --- ## Configuration Examples @@ -170,6 +175,56 @@ Enable entire toolsets, then add individual tools from toolsets you don't want f --- +### Excluding Specific Tools + +**Best for:** Users who want to enable a broad toolset but need to exclude specific tools for security, compliance, or to prevent undesired behavior. + +Listed tools are removed regardless of any other configuration — even if their toolset is enabled or they are individually added. + + + + + + + +
Remote ServerLocal Server
+ +```json +{ + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "X-MCP-Toolsets": "pull_requests", + "X-MCP-Exclude-Tools": "create_pull_request,merge_pull_request" + } +} +``` + + + +```json +{ + "type": "stdio", + "command": "go", + "args": [ + "run", + "./cmd/github-mcp-server", + "stdio", + "--toolsets=pull_requests", + "--exclude-tools=create_pull_request,merge_pull_request" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" + } +} +``` + +
+ +**Result:** All pull request tools except `create_pull_request` and `merge_pull_request` — the user gets read and review tools only. + +--- + ### Read-Only Mode **Best for:** Security conscious users who want to ensure the server won't allow operations that modify issues, pull requests, repositories etc. @@ -231,17 +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": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "X-MCP-Lockdown": "true" + } +} +``` + + + ```json { "type": "stdio", @@ -250,7 +319,7 @@ Starts with only discovery tools (`enable_toolset`, `list_available_toolsets`, ` "run", "./cmd/github-mcp-server", "stdio", - "--dynamic-toolsets" + "--lockdown-mode" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" @@ -258,7 +327,45 @@ Starts with only discovery tools (`enable_toolset`, `list_available_toolsets`, ` } ``` -**With some tools pre-enabled:** +
+ +--- + +### Insiders Mode + +**Best for:** Users who want early access to experimental features and new tools before they reach general availability. + +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. + + + + + +
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" + } +} +``` + + + ```json { "type": "stdio", @@ -267,8 +374,7 @@ Starts with only discovery tools (`enable_toolset`, `list_available_toolsets`, ` "run", "./cmd/github-mcp-server", "stdio", - "--dynamic-toolsets", - "--tools=get_me,search_code" + "--insiders" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" @@ -280,17 +386,26 @@ 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. +See [Insiders Features](./insiders-features.md) for a full list of what's available in Insiders Mode. --- -### Lockdown Mode +### MCP Apps -**Best for:** Public repositories where you want to limit content from users without push access. +[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. -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. +MCP Apps is enabled by [Insiders Mode](#insiders-mode), or independently via the `remote_mcp_ui_apps` feature flag. + +**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). -**Example:** @@ -301,7 +416,7 @@ Lockdown mode ensures the server only surfaces content in public repositories fr "type": "http", "url": "https://api.githubcopilot.com/mcp/", "headers": { - "X-MCP-Lockdown": "true" + "X-MCP-Features": "remote_mcp_ui_apps" } } ``` @@ -317,7 +432,7 @@ Lockdown mode ensures the server only surfaces content in public repositories fr "run", "./cmd/github-mcp-server", "stdio", - "--lockdown-mode" + "--features=remote_mcp_ui_apps" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" @@ -352,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 new file mode 100644 index 0000000000..8f4a2bff84 --- /dev/null +++ b/docs/streamable-http.md @@ -0,0 +1,105 @@ +# Streamable HTTP Server + +The Streamable HTTP mode enables the GitHub MCP Server to run as an HTTP service, allowing clients to connect via standard HTTP protocols. This mode is ideal for deployment scenarios where stdio transport isn't suitable, such as reverse proxy setups, containerized environments, or distributed architectures. + +## Features + +- **Streamable HTTP Transport** — Full HTTP server with streaming support for real-time tool responses +- **OAuth Metadata Endpoints** — Standard `.well-known/oauth-protected-resource` discovery for OAuth clients +- **Scope Challenge Support** — Automatic scope validation with proper HTTP 403 responses and `WWW-Authenticate` headers +- **Scope Filtering** — Restrict available tools based on authenticated credentials and permissions +- **Custom Base Paths** — Support for reverse proxy deployments with customizable base URLs + +## Running the Server + +### Basic HTTP Server + +Start the server on the default port (8082): + +```bash +github-mcp-server http +``` + +The server will be available at `http://localhost:8082`. + +### With Scope Challenge + +Enable scope validation to enforce GitHub permission checks: + +```bash +github-mcp-server http --scope-challenge +``` + +When `--scope-challenge` is enabled, requests with insufficient scopes receive a `403 Forbidden` response with a `WWW-Authenticate` header indicating the required scopes. + +### With OAuth Metadata Discovery + +For use behind reverse proxies or with custom domains, expose OAuth metadata endpoints: + +```bash +github-mcp-server http --scope-challenge --base-url https://myserver.com --base-path /mcp +``` + +The OAuth protected resource metadata's `resource` attribute will be populated with the full URL to the server's protected resource endpoint: + +```json +{ + "resource_name": "GitHub MCP Server", + "resource": "https://myserver.com/mcp", + "authorization_servers": [ + "https://github.com/login/oauth" + ], + "scopes_supported": [ + "repo", + ... + ], + ... +} +``` + +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 + +If your IDE or client has GitHub credentials configured (i.e. VS Code), simply reference the HTTP server: + +```json +{ + "type": "http", + "url": "http://localhost:8082" +} +``` + +The server will use the client's existing GitHub authentication. + +### Using Bearer Tokens or Custom Headers + +To provide PAT credentials, or to customize server behavior preferences, you can include additional headers in the client configuration: + +```json +{ + "type": "http", + "url": "http://localhost:8082", + "headers": { + "Authorization": "Bearer ghp_yourtokenhere", + "X-MCP-Toolsets": "default", + "X-MCP-Readonly": "true" + } +} +``` + +See [Remote Server](./remote-server.md) documentation for more details on client configuration options. diff --git a/docs/toolsets-and-icons.md b/docs/toolsets-and-icons.md index 9c26b4aa10..0e54b1f16a 100644 --- a/docs/toolsets-and-icons.md +++ b/docs/toolsets-and-icons.md @@ -151,6 +151,7 @@ icons := octicons.Icons("repo") | Users | `people` | | Organizations | `organization` | | Actions | `workflow` | +| Code Quality | `code-square` | | Code Security | `codescan` | | Secret Protection | `shield-lock` | | Dependabot | `dependabot` | @@ -161,7 +162,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 86ff45b292..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/v79/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 10bbde9d11..080cdcfd8e 100644 --- a/go.mod +++ b/go.mod @@ -1,53 +1,48 @@ module github.com/github/github-mcp-server -go 1.24.0 +go 1.25.0 require ( - github.com/google/go-github/v79 v79.0.0 - github.com/google/jsonschema-go v0.4.2 - github.com/josephburnett/jd v1.9.2 + github.com/go-chi/chi/v5 v5.3.0 + github.com/go-viper/mapstructure/v2 v2.5.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.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 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + github.com/yosida95/uritemplate/v3 v3.0.2 ) require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-openapi/jsonpointer v0.19.5 // indirect - github.com/go-openapi/swag v0.21.1 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 - github.com/google/go-querystring v1.1.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/lithammer/fuzzysearch v1.1.8 - github.com/mailru/easyjson v0.7.7 // indirect - github.com/modelcontextprotocol/go-sdk v1.2.0 github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 - github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 + github.com/segmentio/asm v1.1.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/spf13/pflag v1.0.10 github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/yosida95/uritemplate/v3 v3.0.2 - github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/net v0.38.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/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.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b364f2ef31..fbf06018f7 100644 --- a/go.sum +++ b/go.sum @@ -1,42 +1,33 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 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-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= -github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.21.1 h1:wm0rhTb5z7qpJRHBdPOMuY4QjVUMbF6/kwoYeRAOrKU= -github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-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.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/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/v79 v79.0.0 h1:MdodQojuFPBhmtwHiBcIGLw/e/wei2PvFX9ndxK0X4Y= -github.com/google/go-github/v79 v79.0.0/go.mod h1:OAFbNhq7fQwohojb06iIIQAB9CBGYLq999myfUFnrS4= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -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/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.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 v1.9.2 h1:ECJRRFXCCqbtidkAHckHGSZm/JIaAxS1gygHLF8MI5Y= -github.com/josephburnett/jd v1.9.2/go.mod h1:bImDr8QXpxMb3SD+w1cDRHp97xP6UwI88xUAuxwDQfM= -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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +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= @@ -46,28 +37,25 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= -github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -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.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= -github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= +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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 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.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +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.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= @@ -85,26 +73,19 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -113,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.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +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= @@ -124,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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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= @@ -140,19 +121,11 @@ 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.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +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= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 37aabb0a62..a37c4d940d 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -6,7 +6,6 @@ import ( "io" "log/slog" "net/http" - "net/url" "os" "os/signal" "strings" @@ -15,100 +14,85 @@ import ( "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/github" + "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" 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" - gogithub "github.com/google/go-github/v79/github" + "github.com/github/github-mcp-server/pkg/utils" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) -type MCPServerConfig struct { - // Version of the server - Version string - - // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) - Host string - - // GitHub Token to authenticate with the GitHub API - Token string - - // EnabledToolsets is a list of toolsets to enable - // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration - EnabledToolsets []string - - // EnabledTools is a list of specific tools to enable (additive to toolsets) - // When specified, these tools are registered in addition to any specified toolset tools - EnabledTools []string - - // EnabledFeatures is a list of feature flags that are enabled - // 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 - - // Translator provides translated text for the server tooling - Translator translations.TranslationHelperFunc - - // Content window size - ContentWindowSize int - - // LockdownMode indicates if we should enable lockdown mode - LockdownMode bool - - // InsidersMode indicates if we should enable experimental features - InsidersMode bool - - // Logger is used for logging within the server - Logger *slog.Logger - // RepoAccessTTL overrides the default TTL for repository access cache entries. - RepoAccessTTL *time.Duration - - // TokenScopes contains the OAuth scopes available to the token. - // When non-nil, tools requiring scopes not in this list will be hidden. - // This is used for PAT scope filtering where we can't issue scope challenges. - TokenScopes []string -} - // githubClients holds all the GitHub API clients created for a server instance. 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. -func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients, error) { +func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolver) (*githubClients, error) { + restURL, err := apiHost.BaseRESTURL(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to get base REST URL: %w", err) + } + + uploadURL, err := apiHost.UploadURL(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to get upload URL: %w", err) + } + + graphQLURL, err := apiHost.GraphqlURL(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to get GraphQL URL: %w", err) + } + + rawURL, err := apiHost.RawURL(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to get Raw URL: %w", err) + } + // Construct REST client - restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token) - restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version) - restClient.BaseURL = apiHost.baseRESTURL - restClient.UploadURL = apiHost.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 gqlHTTPClient := &http.Client{ - Transport: &bearerAuthTransport{ - transport: &github.GraphQLFeaturesTransport{ + Transport: &transport.BearerAuthTransport{ + Transport: &transport.GraphQLFeaturesTransport{ Transport: http.DefaultTransport, }, - token: cfg.Token, + Token: cfg.Token, }, } - gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient) + + gqlClient := githubv4.NewEnterpriseClient(graphQLURL.String(), gqlHTTPClient) // Create raw content client (shares REST client's HTTP transport) - rawClient := raw.NewClient(restClient, apiHost.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 @@ -119,47 +103,21 @@ func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients, 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 } -// resolveEnabledToolsets determines which toolsets should be enabled based on config. -// Returns nil for "use defaults", empty slice for "none", or explicit list. -func resolveEnabledToolsets(cfg MCPServerConfig) []string { - enabledToolsets := cfg.EnabledToolsets - - // In dynamic mode, remove "all" and "default" since users enable toolsets on demand - if cfg.DynamicToolsets && enabledToolsets != nil { - enabledToolsets = github.RemoveToolset(enabledToolsets, string(github.ToolsetMetadataAll.ID)) - enabledToolsets = github.RemoveToolset(enabledToolsets, string(github.ToolsetMetadataDefault.ID)) - } - - if enabledToolsets != nil { - return enabledToolsets - } - if cfg.DynamicToolsets { - // Dynamic mode with no toolsets specified: start empty so users enable on demand - return []string{} - } - if len(cfg.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 - return []string{} - } - // nil means "use defaults" in WithToolsets - return nil -} - -func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { - apiHost, err := parseAPIHost(cfg.Host) +func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Server, error) { + apiHost, err := utils.NewAPIHost(cfg.Host) if err != nil { return nil, fmt.Errorf("failed to parse API host: %w", err) } @@ -169,56 +127,14 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { return nil, fmt.Errorf("failed to create GitHub clients: %w", err) } - enabledToolsets := resolveEnabledToolsets(cfg) - - // Create feature checker - featureChecker := createFeatureChecker(cfg.EnabledFeatures) - - // Build and register the tool/resource/prompt inventory - inventoryBuilder := github.NewInventory(cfg.Translator). - WithDeprecatedAliases(github.DeprecatedToolAliases). - WithReadOnly(cfg.ReadOnly). - WithToolsets(enabledToolsets). - WithTools(cfg.EnabledTools). - WithFeatureChecker(featureChecker). - WithServerInstructions() + // Create feature checker — resolves explicit features + insiders expansion + featureChecker := createFeatureChecker(cfg.EnabledFeatures, cfg.InsidersMode) - // Apply token scope filtering if scopes are known (for PAT filtering) - if cfg.TokenScopes != nil { - inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes)) - } - - inventory, err := inventoryBuilder.Build() + // Create dependencies for tool handlers + obs, err := observability.NewExporters(cfg.Logger, metrics.NewNoopMetrics()) if err != nil { - return nil, fmt.Errorf("failed to build inventory: %w", err) - } - - // Create the MCP server - serverOpts := &mcp.ServerOptions{ - Instructions: inventory.Instructions(), - Logger: cfg.Logger, - CompletionHandler: github.CompletionsHandler(func(_ context.Context) (*gogithub.Client, error) { - return clients.rest, nil - }), - } - - // 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{}, - } + return nil, fmt.Errorf("failed to create observability exporters: %w", err) } - - ghServer := github.NewServer(cfg.Version, serverOpts) - - // Add middlewares - ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext) - ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.rest, clients.gqlHTTP)) - - // Create dependencies for tool handlers deps := github.NewBaseDeps( clients.rest, clients.gql, @@ -227,63 +143,39 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { 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.EnabledToolsets, cfg.EnabledTools)). + WithTools(github.CleanTools(cfg.EnabledTools)). + WithExcludeTools(cfg.ExcludeTools). + WithServerInstructions(). + WithFeatureChecker(featureChecker) - // Inject dependencies into context for all tool handlers - ghServer.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { - return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { - return next(github.ContextWithDeps(ctx, deps), method, req) - } - }) - - if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 { - fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", ")) + // Apply token scope filtering if scopes are known (for PAT filtering) + if cfg.TokenScopes != nil { + inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes)) } - // 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). - inventory.RegisterAll(context.Background(), 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, inventory, deps, cfg.Translator) + inventory, err := inventoryBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build inventory: %w", err) } - return ghServer, nil -} - -// registerDynamicTools adds the dynamic toolset enable/disable tools to the server. -func registerDynamicTools(server *mcp.Server, inventory *inventory.Inventory, deps *github.BaseDeps, t translations.TranslationHelperFunc) { - dynamicDeps := github.DynamicToolDependencies{ - Server: server, - Inventory: inventory, - ToolDeps: deps, - T: t, - } - for _, tool := range github.DynamicTools(inventory) { - tool.RegisterFunc(server, dynamicDeps) + ghServer, err := github.NewMCPServer(ctx, &cfg, deps, inventory) + if err != nil { + return nil, fmt.Errorf("failed to create GitHub MCP server: %w", err) } -} -// 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 - } - return func(_ context.Context, flagName string) (bool, error) { - return featureSet[flagName], nil - } + ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.restUATransp, clients.gqlHTTP)) + + return ghServer, nil } type StdioServerConfig struct { @@ -308,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 @@ -331,9 +219,14 @@ 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. + // These tools will be excluded even if their toolset is enabled or they are + // explicitly listed in EnabledTools. + ExcludeTools []string + // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. RepoAccessCacheTTL *time.Duration } @@ -360,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. @@ -378,19 +271,19 @@ func RunStdioServer(cfg StdioServerConfig) error { logger.Debug("skipping scope filtering for non-PAT token") } - ghServer, err := NewMCPServer(MCPServerConfig{ + ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{ Version: cfg.Version, Host: cfg.Host, Token: cfg.Token, EnabledToolsets: cfg.EnabledToolsets, EnabledTools: cfg.EnabledTools, EnabledFeatures: cfg.EnabledFeatures, - DynamicToolsets: cfg.DynamicToolsets, ReadOnly: cfg.ReadOnly, Translator: t, ContentWindowSize: cfg.ContentWindowSize, LockdownMode: cfg.LockdownMode, InsidersMode: cfg.InsidersMode, + ExcludeTools: cfg.ExcludeTools, Logger: logger, RepoAccessTTL: cfg.RepoAccessCacheTTL, TokenScopes: tokenScopes, @@ -440,214 +333,17 @@ func RunStdioServer(cfg StdioServerConfig) error { return nil } -type apiHost struct { - baseRESTURL *url.URL - graphqlURL *url.URL - uploadURL *url.URL - rawURL *url.URL -} - -func newDotcomHost() (apiHost, error) { - baseRestURL, err := url.Parse("https://api.github.com/") - if err != nil { - return apiHost{}, fmt.Errorf("failed to parse dotcom REST URL: %w", err) - } - - gqlURL, err := url.Parse("https://api.github.com/graphql") - if err != nil { - return apiHost{}, fmt.Errorf("failed to parse dotcom GraphQL URL: %w", err) - } - - uploadURL, err := url.Parse("https://uploads.github.com") - if err != nil { - return apiHost{}, fmt.Errorf("failed to parse dotcom Upload URL: %w", err) - } - - rawURL, err := url.Parse("https://raw.githubusercontent.com/") - if err != nil { - return apiHost{}, fmt.Errorf("failed to parse dotcom Raw URL: %w", err) - } - - return apiHost{ - baseRESTURL: baseRestURL, - graphqlURL: gqlURL, - uploadURL: uploadURL, - rawURL: rawURL, - }, nil -} - -func newGHECHost(hostname string) (apiHost, error) { - u, err := url.Parse(hostname) - if err != nil { - return apiHost{}, fmt.Errorf("failed to parse GHEC URL: %w", err) - } - - // Unsecured GHEC would be an error - if u.Scheme == "http" { - return apiHost{}, fmt.Errorf("GHEC URL must be HTTPS") - } - - restURL, err := url.Parse(fmt.Sprintf("https://api.%s/", u.Hostname())) - if err != nil { - return apiHost{}, fmt.Errorf("failed to parse GHEC REST URL: %w", err) - } - - gqlURL, err := url.Parse(fmt.Sprintf("https://api.%s/graphql", u.Hostname())) - if err != nil { - return apiHost{}, fmt.Errorf("failed to parse GHEC GraphQL URL: %w", err) - } - - uploadURL, err := url.Parse(fmt.Sprintf("https://uploads.%s", u.Hostname())) - if err != nil { - return apiHost{}, fmt.Errorf("failed to parse GHEC Upload URL: %w", err) - } - - rawURL, err := url.Parse(fmt.Sprintf("https://raw.%s/", u.Hostname())) - if err != nil { - return apiHost{}, fmt.Errorf("failed to parse GHEC Raw URL: %w", err) - } - - return apiHost{ - baseRESTURL: restURL, - graphqlURL: gqlURL, - uploadURL: uploadURL, - rawURL: rawURL, - }, nil -} - -func newGHESHost(hostname string) (apiHost, error) { - u, err := url.Parse(hostname) - if err != nil { - return apiHost{}, fmt.Errorf("failed to parse GHES URL: %w", err) - } - - restURL, err := url.Parse(fmt.Sprintf("%s://%s/api/v3/", u.Scheme, u.Hostname())) - if err != nil { - return apiHost{}, fmt.Errorf("failed to parse GHES REST URL: %w", err) - } - - gqlURL, err := url.Parse(fmt.Sprintf("%s://%s/api/graphql", u.Scheme, u.Hostname())) - if err != nil { - return apiHost{}, fmt.Errorf("failed to parse GHES GraphQL URL: %w", err) - } - - // Check if subdomain isolation is enabled - // See https://docs.github.com/en/enterprise-server@3.17/admin/configuring-settings/hardening-security-for-your-enterprise/enabling-subdomain-isolation#about-subdomain-isolation - hasSubdomainIsolation := checkSubdomainIsolation(u.Scheme, u.Hostname()) - - var uploadURL *url.URL - if hasSubdomainIsolation { - // With subdomain isolation: https://uploads.hostname/ - uploadURL, err = url.Parse(fmt.Sprintf("%s://uploads.%s/", u.Scheme, u.Hostname())) - } else { - // Without subdomain isolation: https://hostname/api/uploads/ - uploadURL, err = url.Parse(fmt.Sprintf("%s://%s/api/uploads/", u.Scheme, u.Hostname())) - } - if err != nil { - return apiHost{}, fmt.Errorf("failed to parse GHES Upload URL: %w", err) - } - - var rawURL *url.URL - if hasSubdomainIsolation { - // With subdomain isolation: https://raw.hostname/ - rawURL, err = url.Parse(fmt.Sprintf("%s://raw.%s/", u.Scheme, u.Hostname())) - } else { - // Without subdomain isolation: https://hostname/raw/ - rawURL, err = url.Parse(fmt.Sprintf("%s://%s/raw/", u.Scheme, u.Hostname())) - } - if err != nil { - return apiHost{}, fmt.Errorf("failed to parse GHES Raw URL: %w", err) - } - - return apiHost{ - baseRESTURL: restURL, - graphqlURL: gqlURL, - uploadURL: uploadURL, - rawURL: rawURL, - }, nil -} - -// checkSubdomainIsolation detects if GitHub Enterprise Server has subdomain isolation enabled -// by attempting to ping the raw./_ping endpoint on the subdomain. The raw subdomain must always exist for subdomain isolation. -func checkSubdomainIsolation(scheme, hostname string) bool { - subdomainURL := fmt.Sprintf("%s://raw.%s/_ping", scheme, hostname) - - client := &http.Client{ - Timeout: 5 * time.Second, - // Don't follow redirects - we just want to check if the endpoint exists - //nolint:revive // parameters are required by http.Client.CheckRedirect signature - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - - resp, err := client.Get(subdomainURL) - if err != nil { - return false - } - defer resp.Body.Close() - - return resp.StatusCode == http.StatusOK -} - -// Note that this does not handle ports yet, so development environments are out. -func parseAPIHost(s string) (apiHost, error) { - if s == "" { - return newDotcomHost() - } - - u, err := url.Parse(s) - if err != nil { - return apiHost{}, fmt.Errorf("could not parse host as URL: %s", s) - } - - if u.Scheme == "" { - return apiHost{}, fmt.Errorf("host must have a scheme (http or https): %s", s) - } - - if strings.HasSuffix(u.Hostname(), "github.com") { - return newDotcomHost() - } - - if strings.HasSuffix(u.Hostname(), "ghe.com") { - return newGHECHost(s) - } - - return newGHESHost(s) -} - -type userAgentTransport struct { - transport http.RoundTripper - agent string -} - -func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req = req.Clone(req.Context()) - req.Header.Set("User-Agent", t.agent) - return t.transport.RoundTrip(req) -} - -type bearerAuthTransport struct { - transport http.RoundTripper - token string -} - -func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req = req.Clone(req.Context()) - req.Header.Set("Authorization", "Bearer "+t.token) - return t.transport.RoundTrip(req) -} - -func addGitHubAPIErrorToContext(next mcp.MethodHandler) mcp.MethodHandler { - return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, err error) { - // Ensure the context is cleared of any previous errors - // as context isn't propagated through middleware - ctx = errors.ContextWithGitHubErrors(ctx) - return next(ctx, method, req) +// 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 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" { @@ -666,12 +362,15 @@ func addUserAgentsMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, g message.Params.ClientInfo.Name, message.Params.ClientInfo.Version, ) + if cfg.InsidersMode { + userAgent += " (insiders)" + } - restClient.UserAgent = userAgent + restUATransp.Agent = userAgent - gqlHTTPClient.Transport = &userAgentTransport{ - transport: gqlHTTPClient.Transport, - agent: userAgent, + gqlHTTPClient.Transport = &transport.UserAgentTransport{ + Transport: gqlHTTPClient.Transport, + Agent: userAgent, } return next(ctx, method, request) @@ -682,14 +381,12 @@ func addUserAgentsMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, g // fetchTokenScopesForHost fetches the OAuth scopes for a token from the GitHub API. // It constructs the appropriate API host URL based on the configured host. func fetchTokenScopesForHost(ctx context.Context, token, host string) ([]string, error) { - apiHost, err := parseAPIHost(host) + apiHost, err := utils.NewAPIHost(host) if err != nil { return nil, fmt.Errorf("failed to parse API host: %w", err) } - fetcher := scopes.NewFetcher(scopes.FetcherOptions{ - APIHost: apiHost.baseRESTURL.String(), - }) + fetcher := scopes.NewFetcher(apiHost, scopes.FetcherOptions{}) return fetcher.FetchTokenScopes(ctx, token) } diff --git a/internal/ghmcp/server_test.go b/internal/ghmcp/server_test.go index 2139aa280f..6f0e3ac3f3 100644 --- a/internal/ghmcp/server_test.go +++ b/internal/ghmcp/server_test.go @@ -1,113 +1 @@ package ghmcp - -import ( - "testing" - - "github.com/github/github-mcp-server/pkg/translations" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestNewMCPServer_CreatesSuccessfully verifies that the server can be created -// with the deps injection middleware properly configured. -func TestNewMCPServer_CreatesSuccessfully(t *testing.T) { - t.Parallel() - - // Create a minimal server configuration - cfg := MCPServerConfig{ - Version: "test", - Host: "", // defaults to github.com - Token: "test-token", - EnabledToolsets: []string{"context"}, - ReadOnly: false, - Translator: translations.NullTranslationHelper, - ContentWindowSize: 5000, - LockdownMode: false, - InsidersMode: false, - } - - // Create the server - server, err := NewMCPServer(cfg) - require.NoError(t, err, "expected server creation to succeed") - require.NotNil(t, server, "expected server to be non-nil") - - // The fact that the server was created successfully indicates that: - // 1. The deps injection middleware is properly added - // 2. Tools can be registered without panicking - // - // If the middleware wasn't properly added, tool calls would panic with - // "ToolDependencies not found in context" when executed. - // - // The actual middleware functionality and tool execution with ContextWithDeps - // is already tested in pkg/github/*_test.go. -} - -// TestResolveEnabledToolsets verifies the toolset resolution logic. -func TestResolveEnabledToolsets(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - cfg MCPServerConfig - expectedResult []string - }{ - { - name: "nil toolsets without dynamic mode 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"}, - }, - { - name: "empty toolsets - disable all", - cfg: MCPServerConfig{ - EnabledToolsets: []string{}, - DynamicToolsets: false, - }, - expectedResult: []string{}, // empty slice means no toolsets - }, - { - 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 := resolveEnabledToolsets(tc.cfg) - assert.Equal(t, tc.expectedResult, result) - }) - } -} diff --git a/internal/toolsnaps/toolsnaps_test.go b/internal/toolsnaps/toolsnaps_test.go index c7d7301bca..b1138df866 100644 --- a/internal/toolsnaps/toolsnaps_test.go +++ b/internal/toolsnaps/toolsnaps_test.go @@ -195,23 +195,23 @@ func TestToolSnapKeysSorted(t *testing.T) { // Given a tool with fields that could be in any order type complexTool struct { - Name string `json:"name"` - Description string `json:"description"` - Properties map[string]interface{} `json:"properties"` - Annotations map[string]interface{} `json:"annotations"` + Name string `json:"name"` + Description string `json:"description"` + Properties map[string]any `json:"properties"` + Annotations map[string]any `json:"annotations"` } tool := complexTool{ Name: "test_tool", Description: "A test tool", - Properties: map[string]interface{}{ + Properties: map[string]any{ "zzz": "last", "aaa": "first", "mmm": "middle", - "owner": map[string]interface{}{"type": "string", "description": "Owner"}, - "repo": map[string]interface{}{"type": "string", "description": "Repo"}, + "owner": map[string]any{"type": "string", "description": "Owner"}, + "repo": map[string]any{"type": "string", "description": "Repo"}, }, - Annotations: map[string]interface{}{ + Annotations: map[string]any{ "readOnly": true, "title": "Test", }, @@ -227,7 +227,7 @@ func TestToolSnapKeysSorted(t *testing.T) { require.NoError(t, err) // Verify that the JSON is properly sorted by checking key order - var parsed map[string]interface{} + var parsed map[string]any err = json.Unmarshal(snapJSON, &parsed) require.NoError(t, err) @@ -285,7 +285,7 @@ func TestStructFieldOrderingSortedAlphabetically(t *testing.T) { aFieldIndex := -1 mFieldIndex := -1 zFieldIndex := -1 - for i := 0; i < len(snapStr)-7; i++ { + for i := range len(snapStr) - 7 { switch snapStr[i : i+6] { case "aField": aFieldIndex = i diff --git a/pkg/buffer/buffer.go b/pkg/buffer/buffer.go index 54ed0be4d8..23cc818e1f 100644 --- a/pkg/buffer/buffer.go +++ b/pkg/buffer/buffer.go @@ -32,6 +32,9 @@ const maxLineSize = 10 * 1024 * 1024 // If the response contains more lines than maxJobLogLines, only the most recent lines are kept. // Lines exceeding maxLineSize are truncated with a marker. func ProcessResponseAsRingBufferToEnd(httpResp *http.Response, maxJobLogLines int) (string, int, *http.Response, error) { + if maxJobLogLines <= 0 { + maxJobLogLines = 500 + } if maxJobLogLines > 100000 { maxJobLogLines = 100000 } @@ -112,17 +115,14 @@ func ProcessResponseAsRingBufferToEnd(httpResp *http.Response, maxJobLogLines in } var result []string - linesInBuffer := totalLines - if linesInBuffer > maxJobLogLines { - linesInBuffer = maxJobLogLines - } + linesInBuffer := min(totalLines, maxJobLogLines) startIndex := 0 if totalLines > maxJobLogLines { startIndex = writeIndex } - for i := 0; i < linesInBuffer; i++ { + for i := range linesInBuffer { idx := (startIndex + i) % maxJobLogLines if validLines[idx] { result = append(result, lines[idx]) diff --git a/pkg/context/graphql_features.go b/pkg/context/graphql_features.go new file mode 100644 index 0000000000..ebba3f757b --- /dev/null +++ b/pkg/context/graphql_features.go @@ -0,0 +1,19 @@ +package context + +import "context" + +// graphQLFeaturesKey is a context key for GraphQL feature flags +type graphQLFeaturesKey struct{} + +// withGraphQLFeatures adds GraphQL feature flags to the context +func WithGraphQLFeatures(ctx context.Context, features ...string) context.Context { + return context.WithValue(ctx, graphQLFeaturesKey{}, features) +} + +// GetGraphQLFeatures retrieves GraphQL feature flags from the context +func GetGraphQLFeatures(ctx context.Context) []string { + if features, ok := ctx.Value(graphQLFeaturesKey{}).([]string); ok { + return features + } + return nil +} diff --git a/pkg/context/mcp_info.go b/pkg/context/mcp_info.go new file mode 100644 index 0000000000..ce55056821 --- /dev/null +++ b/pkg/context/mcp_info.go @@ -0,0 +1,39 @@ +package context + +import "context" + +type mcpMethodInfoCtx string + +var mcpMethodInfoCtxKey mcpMethodInfoCtx = "mcpmethodinfo" + +// MCPMethodInfo contains pre-parsed MCP method information extracted from the JSON-RPC request. +// This is populated early in the request lifecycle to enable: +// - Inventory filtering via ForMCPRequest (only register needed tools/resources/prompts) +// - Avoiding duplicate JSON parsing in middlewares (secret-scanning, scope-challenge) +// - Performance optimization for per-request server creation +type MCPMethodInfo struct { + // Method is the MCP method being called (e.g., "tools/call", "tools/list", "initialize") + Method string + // ItemName is the name of the specific item being accessed (tool name, resource URI, prompt name) + // Only populated for call/get methods (tools/call, prompts/get, resources/read) + ItemName string + // Owner is the repository owner from tool call arguments, if present + Owner string + // Repo is the repository name from tool call arguments, if present + Repo string + // Arguments contains the raw tool arguments for tools/call requests + Arguments map[string]any +} + +// WithMCPMethodInfo stores the MCPMethodInfo in the context. +func WithMCPMethodInfo(ctx context.Context, info *MCPMethodInfo) context.Context { + return context.WithValue(ctx, mcpMethodInfoCtxKey, info) +} + +// MCPMethod retrieves the MCPMethodInfo from the context. +func MCPMethod(ctx context.Context) (*MCPMethodInfo, bool) { + if info, ok := ctx.Value(mcpMethodInfoCtxKey).(*MCPMethodInfo); ok { + return info, true + } + return nil, false +} diff --git a/pkg/context/request.go b/pkg/context/request.go new file mode 100644 index 0000000000..6d8d8a1060 --- /dev/null +++ b/pkg/context/request.go @@ -0,0 +1,131 @@ +package context + +import "context" + +// readonlyCtxKey is a context key for read-only mode +type readonlyCtxKey struct{} + +// WithReadonly adds read-only mode state to the context +func WithReadonly(ctx context.Context, enabled bool) context.Context { + return context.WithValue(ctx, readonlyCtxKey{}, enabled) +} + +// IsReadonly retrieves the read-only mode state from the context +func IsReadonly(ctx context.Context) bool { + if enabled, ok := ctx.Value(readonlyCtxKey{}).(bool); ok { + return enabled + } + return false +} + +// toolsetsCtxKey is a context key for the active toolsets +type toolsetsCtxKey struct{} + +// WithToolsets adds the active toolsets to the context +func WithToolsets(ctx context.Context, toolsets []string) context.Context { + return context.WithValue(ctx, toolsetsCtxKey{}, toolsets) +} + +// GetToolsets retrieves the active toolsets from the context +func GetToolsets(ctx context.Context) []string { + if toolsets, ok := ctx.Value(toolsetsCtxKey{}).([]string); ok { + return toolsets + } + return nil +} + +// toolsCtxKey is a context key for tools +type toolsCtxKey struct{} + +// WithTools adds the tools to the context +func WithTools(ctx context.Context, tools []string) context.Context { + return context.WithValue(ctx, toolsCtxKey{}, tools) +} + +// GetTools retrieves the tools from the context +func GetTools(ctx context.Context) []string { + if tools, ok := ctx.Value(toolsCtxKey{}).([]string); ok { + return tools + } + return nil +} + +// lockdownCtxKey is a context key for lockdown mode +type lockdownCtxKey struct{} + +// WithLockdownMode adds lockdown mode state to the context +func WithLockdownMode(ctx context.Context, enabled bool) context.Context { + return context.WithValue(ctx, lockdownCtxKey{}, enabled) +} + +// IsLockdownMode retrieves the lockdown mode state from the context +func IsLockdownMode(ctx context.Context) bool { + if enabled, ok := ctx.Value(lockdownCtxKey{}).(bool); ok { + return enabled + } + return false +} + +// insidersCtxKey is a context key for insiders mode +type insidersCtxKey struct{} + +// WithInsidersMode adds insiders mode state to the context +func WithInsidersMode(ctx context.Context, enabled bool) context.Context { + return context.WithValue(ctx, insidersCtxKey{}, enabled) +} + +// IsInsidersMode retrieves the insiders mode state from the context +func IsInsidersMode(ctx context.Context) bool { + if enabled, ok := ctx.Value(insidersCtxKey{}).(bool); ok { + return enabled + } + return false +} + +// excludeToolsCtxKey is a context key for excluded tools +type excludeToolsCtxKey struct{} + +// WithExcludeTools adds the excluded tools to the context +func WithExcludeTools(ctx context.Context, tools []string) context.Context { + return context.WithValue(ctx, excludeToolsCtxKey{}, tools) +} + +// GetExcludeTools retrieves the excluded tools from the context +func GetExcludeTools(ctx context.Context) []string { + if tools, ok := ctx.Value(excludeToolsCtxKey{}).([]string); ok { + return tools + } + return nil +} + +// headerFeaturesCtxKey is a context key for raw header feature flags +type headerFeaturesCtxKey struct{} + +// WithHeaderFeatures stores the raw feature flags from the X-MCP-Features header into context +func WithHeaderFeatures(ctx context.Context, features []string) context.Context { + return context.WithValue(ctx, headerFeaturesCtxKey{}, features) +} + +// GetHeaderFeatures retrieves the raw feature flags from context +func GetHeaderFeatures(ctx context.Context) []string { + if features, ok := ctx.Value(headerFeaturesCtxKey{}).([]string); ok { + return features + } + return nil +} + +// uiSupportCtxKey is a context key for MCP Apps UI support +type uiSupportCtxKey struct{} + +// WithUISupport stores whether the client supports MCP Apps UI in the context. +// This is used by HTTP/stateless servers where the go-sdk session may not +// persist client capabilities across requests. +func WithUISupport(ctx context.Context, supported bool) context.Context { + return context.WithValue(ctx, uiSupportCtxKey{}, supported) +} + +// HasUISupport retrieves the MCP Apps UI support flag from context. +func HasUISupport(ctx context.Context) (supported bool, ok bool) { + v, ok := ctx.Value(uiSupportCtxKey{}).(bool) + return v, ok +} diff --git a/pkg/context/token.go b/pkg/context/token.go new file mode 100644 index 0000000000..97091a922f --- /dev/null +++ b/pkg/context/token.go @@ -0,0 +1,42 @@ +package context + +import ( + "context" + + "github.com/github/github-mcp-server/pkg/utils" +) + +type tokenCtxKey struct{} + +type TokenInfo struct { + Token string + TokenType utils.TokenType +} + +// WithTokenInfo adds TokenInfo to the context +func WithTokenInfo(ctx context.Context, tokenInfo *TokenInfo) context.Context { + return context.WithValue(ctx, tokenCtxKey{}, tokenInfo) +} + +// GetTokenInfo retrieves the authentication token from the context +func GetTokenInfo(ctx context.Context) (*TokenInfo, bool) { + if tokenInfo, ok := ctx.Value(tokenCtxKey{}).(*TokenInfo); ok { + return tokenInfo, true + } + return nil, false +} + +type tokenScopesKey struct{} + +// WithTokenScopes adds token scopes to the context +func WithTokenScopes(ctx context.Context, scopes []string) context.Context { + return context.WithValue(ctx, tokenScopesKey{}, scopes) +} + +// GetTokenScopes retrieves token scopes from the context +func GetTokenScopes(ctx context.Context) ([]string, bool) { + if scopes, ok := ctx.Value(tokenScopesKey{}).([]string); ok { + return scopes, true + } + return nil, false +} diff --git a/pkg/errors/error.go b/pkg/errors/error.go index 93ea852a87..a1b35d697d 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -2,11 +2,13 @@ package errors import ( "context" + stderrors "errors" "fmt" "net/http" + "time" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -159,6 +161,35 @@ func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github if ctx != nil { _, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling } + + var rateLimitErr *github.RateLimitError + if stderrors.As(err, &rateLimitErr) { + resetTime := rateLimitErr.Rate.Reset.Time + if !resetTime.IsZero() { + retryIn := time.Until(resetTime).Round(time.Second) + if retryIn > 0 { + return utils.NewToolResultError(fmt.Sprintf( + "%s: GitHub API rate limit exceeded. Retry after %v.", message, retryIn)) + } + } + return utils.NewToolResultError(fmt.Sprintf( + "%s: GitHub API rate limit exceeded. Wait before retrying.", message)) + } + + var abuseErr *github.AbuseRateLimitError + if stderrors.As(err, &abuseErr) { + if abuseErr.RetryAfter != nil { + retryAfter := abuseErr.RetryAfter.Round(time.Second) + if retryAfter > 0 { + return utils.NewToolResultError(fmt.Sprintf( + "%s: GitHub secondary rate limit exceeded. Retry after %v.", + message, retryAfter)) + } + } + return utils.NewToolResultError(fmt.Sprintf( + "%s: GitHub secondary rate limit exceeded. Wait before retrying.", message)) + } + return utils.NewToolResultErrorFromErr(message, err) } diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go index 072a09a289..3c899b6b58 100644 --- a/pkg/errors/error_test.go +++ b/pkg/errors/error_test.go @@ -3,12 +3,13 @@ package errors import ( "context" "fmt" - "net/http" - "testing" - - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "net/http" + "testing" + "time" ) func TestGitHubErrorContext(t *testing.T) { @@ -460,3 +461,229 @@ func TestMiddlewareScenario(t *testing.T) { assert.Contains(t, gqlMessages, "mutation failed") }) } + +// requireErrorText asserts that result is a non-nil MCP tool error and returns its text content. +func requireErrorText(t *testing.T, result *mcp.CallToolResult) string { + t.Helper() + require.NotNil(t, result) + require.True(t, result.IsError) + require.NotEmpty(t, result.Content) + text, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok, "expected *mcp.TextContent, got %T", result.Content[0]) + return text.Text +} + +// assertContextHasError asserts that exactly one error is stored in ctx and it matches expectedErr. +// +//nolint:revive // t must be first for test helpers; context-as-argument doesn't apply here +func assertContextHasError(t *testing.T, ctx context.Context, expectedErr error) { + t.Helper() + apiErrors, err := GetGitHubAPIErrors(ctx) + require.NoError(t, err) + require.Len(t, apiErrors, 1) + assert.Equal(t, expectedErr, apiErrors[0].Err) +} + +func TestNewGitHubAPIErrorResponse_RateLimits(t *testing.T) { + t.Run("RateLimitError produces clean message with retry time", func(t *testing.T) { + // Given a context with GitHub error tracking enabled + ctx := ContextWithGitHubErrors(context.Background()) + + resetTime := time.Now().Add(30 * time.Minute) + rateLimitErr := &github.RateLimitError{ + Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}}, + Response: &http.Response{StatusCode: 403}, + Message: "API rate limit exceeded", + } + resp := &github.Response{Response: rateLimitErr.Response} + + // Capture expected duration before the call so both use the same time.Until snapshot + expectedRetryIn := time.Until(resetTime).Round(time.Second) + + // When we create an API error response for a rate limit error + result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr) + + // Then the message should be clean and actionable (no raw URLs or status codes) + text := requireErrorText(t, result) + assert.Contains(t, text, fmt.Sprintf("GitHub API rate limit exceeded. Retry after %v.", expectedRetryIn)) + assert.NotContains(t, text, "https://") + assert.NotContains(t, text, "403") + + // And the original error should still be stored in context for middleware + assertContextHasError(t, ctx, rateLimitErr) + }) + + t.Run("AbuseRateLimitError with RetryAfter produces clean message with wait time", func(t *testing.T) { + // Given a context with GitHub error tracking enabled + ctx := ContextWithGitHubErrors(context.Background()) + + retryAfter := 47 * time.Second + abuseErr := &github.AbuseRateLimitError{ + Response: &http.Response{StatusCode: 403}, + Message: "You have exceeded a secondary rate limit.", + RetryAfter: &retryAfter, + } + resp := &github.Response{Response: abuseErr.Response} + + // When we create an API error response for a secondary rate limit error + result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, abuseErr) + + // And the message should include the specific retry duration + text := requireErrorText(t, result) + assert.Contains(t, text, "GitHub secondary rate limit exceeded. Retry after 47s.") + assert.NotContains(t, text, "https://") + assert.NotContains(t, text, "403") + + // And the original error should still be stored in context for middleware + assertContextHasError(t, ctx, abuseErr) + }) + + t.Run("AbuseRateLimitError without RetryAfter produces clean message without wait time", func(t *testing.T) { + // Given a context with GitHub error tracking enabled + ctx := ContextWithGitHubErrors(context.Background()) + + abuseErr := &github.AbuseRateLimitError{ + Response: &http.Response{StatusCode: 403}, + Message: "You have exceeded a secondary rate limit.", + RetryAfter: nil, + } + resp := &github.Response{Response: abuseErr.Response} + + // When we create an API error response for a secondary rate limit error without retry info + result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, abuseErr) + + // And the message should be clean and actionable + text := requireErrorText(t, result) + assert.Contains(t, text, "GitHub secondary rate limit exceeded. Wait before retrying.") + assert.NotContains(t, text, "https://") + assert.NotContains(t, text, "403") + + // And the original error should still be stored in context for middleware + assertContextHasError(t, ctx, abuseErr) + }) + + t.Run("AbuseRateLimitError with sub-second RetryAfter falls back to wait message", func(t *testing.T) { + ctx := ContextWithGitHubErrors(context.Background()) + + // 200ms rounds to 0s, so should fall back to the generic wait message + retryAfter := 200 * time.Millisecond + abuseErr := &github.AbuseRateLimitError{ + Response: &http.Response{StatusCode: 403}, + Message: "You have exceeded a secondary rate limit.", + RetryAfter: &retryAfter, + } + resp := &github.Response{Response: abuseErr.Response} + + result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, abuseErr) + + text := requireErrorText(t, result) + assert.Contains(t, text, "GitHub secondary rate limit exceeded. Wait before retrying.") + }) + + t.Run("RateLimitError with reset time in the past falls back to wait message", func(t *testing.T) { + ctx := ContextWithGitHubErrors(context.Background()) + + resetTime := time.Now().Add(-5 * time.Second) // already passed + rateLimitErr := &github.RateLimitError{ + Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}}, + Response: &http.Response{StatusCode: 403}, + Message: "API rate limit exceeded", + } + resp := &github.Response{Response: rateLimitErr.Response} + + result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr) + + text := requireErrorText(t, result) + assert.Contains(t, text, "GitHub API rate limit exceeded. Wait before retrying.") + }) + + t.Run("RateLimitError with sub-second reset time falls back to wait message", func(t *testing.T) { + ctx := ContextWithGitHubErrors(context.Background()) + + // 250ms in the future: still positive, but rounds to 0s, so should fall back + resetTime := time.Now().Add(250 * time.Millisecond) + rateLimitErr := &github.RateLimitError{ + Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}}, + Response: &http.Response{StatusCode: 403}, + Message: "API rate limit exceeded", + } + resp := &github.Response{Response: rateLimitErr.Response} + + result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr) + + text := requireErrorText(t, result) + assert.Contains(t, text, "GitHub API rate limit exceeded. Wait before retrying.") + }) + + t.Run("RateLimitError with zero reset time falls back to wait message", func(t *testing.T) { + ctx := ContextWithGitHubErrors(context.Background()) + + rateLimitErr := &github.RateLimitError{ + Rate: github.Rate{}, // zero Reset time + Response: &http.Response{StatusCode: 403}, + Message: "API rate limit exceeded", + } + resp := &github.Response{Response: rateLimitErr.Response} + + result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr) + + text := requireErrorText(t, result) + assert.Contains(t, text, "GitHub API rate limit exceeded. Wait before retrying.") + }) + + t.Run("wrapped RateLimitError is handled via errors.As", func(t *testing.T) { + ctx := ContextWithGitHubErrors(context.Background()) + + resetTime := time.Now().Add(20 * time.Minute) + rateLimitErr := &github.RateLimitError{ + Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}}, + Response: &http.Response{StatusCode: 403}, + Message: "API rate limit exceeded", + } + wrappedErr := fmt.Errorf("transport layer: %w", rateLimitErr) + resp := &github.Response{Response: rateLimitErr.Response} + + // Capture expected duration before the call so both use the same time.Until snapshot + expectedRetryIn := time.Until(resetTime).Round(time.Second) + + result := NewGitHubAPIErrorResponse(ctx, "search code", resp, wrappedErr) + + text := requireErrorText(t, result) + assert.Contains(t, text, fmt.Sprintf("GitHub API rate limit exceeded. Retry after %v.", expectedRetryIn)) + assert.NotContains(t, text, "https://") + }) + + t.Run("wrapped AbuseRateLimitError is handled via errors.As", func(t *testing.T) { + ctx := ContextWithGitHubErrors(context.Background()) + + retryAfter := 30 * time.Second + abuseErr := &github.AbuseRateLimitError{ + Response: &http.Response{StatusCode: 403}, + Message: "secondary rate limit", + RetryAfter: &retryAfter, + } + wrappedErr := fmt.Errorf("transport layer: %w", abuseErr) + resp := &github.Response{Response: abuseErr.Response} + + result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, wrappedErr) + + text := requireErrorText(t, result) + assert.Contains(t, text, "GitHub secondary rate limit exceeded. Retry after 30s.") + assert.NotContains(t, text, "https://") + }) + + t.Run("non-rate-limit GitHub API error passes through the original error message", func(t *testing.T) { + // Given a context with GitHub error tracking enabled + ctx := ContextWithGitHubErrors(context.Background()) + + resp := &github.Response{Response: &http.Response{StatusCode: 422}} + originalErr := fmt.Errorf("validation failed") + + // When we create an API error response for a non-rate-limit error + result := NewGitHubAPIErrorResponse(ctx, "API call failed", resp, originalErr) + + // Then the message should contain the original error text unchanged + text := requireErrorText(t, result) + assert.Contains(t, text, "validation failed") + }) +} 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_issue_comment.snap b/pkg/github/__toolsnaps__/add_issue_comment.snap index d273a582d6..5479a16a60 100644 --- a/pkg/github/__toolsnaps__/add_issue_comment.snap +++ b/pkg/github/__toolsnaps__/add_issue_comment.snap @@ -1,6 +1,6 @@ { "annotations": { - "title": "Add comment to issue" + "title": "Add comment to issue or pull request" }, "description": "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments.", "inputSchema": { diff --git a/pkg/github/__toolsnaps__/add_project_item.snap b/pkg/github/__toolsnaps__/add_project_item.snap deleted file mode 100644 index e6a5cc3c46..0000000000 --- a/pkg/github/__toolsnaps__/add_project_item.snap +++ /dev/null @@ -1,47 +0,0 @@ -{ - "annotations": { - "title": "Add project item" - }, - "description": "Add a specific Project item for a user or org", - "inputSchema": { - "properties": { - "item_id": { - "description": "The numeric ID of the issue or pull request to add to the project.", - "type": "number" - }, - "item_type": { - "description": "The item's type, either issue or pull_request.", - "enum": [ - "issue", - "pull_request" - ], - "type": "string" - }, - "owner": { - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - "type": "string" - }, - "owner_type": { - "description": "Owner type", - "enum": [ - "user", - "org" - ], - "type": "string" - }, - "project_number": { - "description": "The project's number.", - "type": "number" - } - }, - "required": [ - "owner_type", - "owner", - "project_number", - "item_type", - "item_id" - ], - "type": "object" - }, - "name": "add_project_item" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/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_reply_to_pull_request_comment.snap b/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap new file mode 100644 index 0000000000..e2187478e8 --- /dev/null +++ b/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap @@ -0,0 +1,39 @@ +{ + "annotations": { + "title": "Add reply to pull request comment" + }, + "description": "Add a reply to an existing pull request comment. This creates a new comment that is linked as a reply to the specified comment.", + "inputSchema": { + "properties": { + "body": { + "description": "The text of the reply", + "type": "string" + }, + "commentId": { + "description": "The ID of the comment to reply to", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "pullNumber": { + "description": "Pull request number", + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "pullNumber", + "commentId", + "body" + ], + "type": "object" + }, + "name": "add_reply_to_pull_request_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__/cancel_workflow_run.snap b/pkg/github/__toolsnaps__/cancel_workflow_run.snap deleted file mode 100644 index 40bcae7401..0000000000 --- a/pkg/github/__toolsnaps__/cancel_workflow_run.snap +++ /dev/null @@ -1,29 +0,0 @@ -{ - "annotations": { - "title": "Cancel workflow run" - }, - "description": "Cancel a workflow run", - "inputSchema": { - "properties": { - "owner": { - "description": "Repository owner", - "type": "string" - }, - "repo": { - "description": "Repository name", - "type": "string" - }, - "run_id": { - "description": "The unique identifier of the workflow run", - "type": "number" - } - }, - "required": [ - "owner", - "repo", - "run_id" - ], - "type": "object" - }, - "name": "cancel_workflow_run" -} \ 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_or_update_file.snap b/pkg/github/__toolsnaps__/create_or_update_file.snap index 9d28c80854..e6900c9053 100644 --- a/pkg/github/__toolsnaps__/create_or_update_file.snap +++ b/pkg/github/__toolsnaps__/create_or_update_file.snap @@ -2,7 +2,7 @@ "annotations": { "title": "Create or update file" }, - "description": "Create or update a single file in a GitHub repository. \nIf updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.\n\nIn order to obtain the SHA of original file version before updating, use the following git command:\ngit ls-tree HEAD \u003cpath to file\u003e\n\nIf the SHA is not provided, the tool will attempt to acquire it by fetching the current file contents from the repository, which may lead to rewriting latest committed changes if the file has changed since last retrieval.\n", + "description": "Create or update a single file in a GitHub repository. \nIf updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.\n\nIn order to obtain the SHA of original file version before updating, use the following git command:\ngit rev-parse \u003cbranch\u003e:\u003cpath to file\u003e\n\nSHA MUST be provided for existing file updates.\n", "inputSchema": { "properties": { "branch": { @@ -30,7 +30,7 @@ "type": "string" }, "sha": { - "description": "The blob SHA of the file being replaced.", + "description": "The blob SHA of the file being replaced. Required if the file already exists.", "type": "string" } }, diff --git a/pkg/github/__toolsnaps__/create_pull_request.snap b/pkg/github/__toolsnaps__/create_pull_request.snap index cc22897faa..b2f14e3908 100644 --- a/pkg/github/__toolsnaps__/create_pull_request.snap +++ b/pkg/github/__toolsnaps__/create_pull_request.snap @@ -1,4 +1,13 @@ { + "_meta": { + "ui": { + "resourceUri": "ui://github-mcp-server/pr-write", + "visibility": [ + "model", + "app" + ] + } + }, "annotations": { "title": "Open new pull request" }, @@ -33,6 +42,17 @@ "description": "Repository name", "type": "string" }, + "reviewers": { + "description": "GitHub usernames or ORG/team-slug team reviewers to request reviews from", + "items": { + "type": "string" + }, + "type": "array" + }, + "show_ui": { + "description": "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action.", + "type": "boolean" + }, "title": { "description": "PR title", "type": "string" 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__/create_repository.snap b/pkg/github/__toolsnaps__/create_repository.snap index 2cc4227b23..0aa2123673 100644 --- a/pkg/github/__toolsnaps__/create_repository.snap +++ b/pkg/github/__toolsnaps__/create_repository.snap @@ -22,7 +22,8 @@ "type": "string" }, "private": { - "description": "Whether repo should be private", + "default": true, + "description": "Whether the repository should be private. Defaults to true (private) when omitted.", "type": "boolean" } }, 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__/delete_project_item.snap b/pkg/github/__toolsnaps__/delete_project_item.snap deleted file mode 100644 index 819fb84743..0000000000 --- a/pkg/github/__toolsnaps__/delete_project_item.snap +++ /dev/null @@ -1,39 +0,0 @@ -{ - "annotations": { - "destructiveHint": true, - "title": "Delete project item" - }, - "description": "Delete a specific Project item for a user or org", - "inputSchema": { - "properties": { - "item_id": { - "description": "The internal project item ID to delete from the project (not the issue or pull request ID).", - "type": "number" - }, - "owner": { - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - "type": "string" - }, - "owner_type": { - "description": "Owner type", - "enum": [ - "user", - "org" - ], - "type": "string" - }, - "project_number": { - "description": "The project's number.", - "type": "number" - } - }, - "required": [ - "owner_type", - "owner", - "project_number", - "item_id" - ], - "type": "object" - }, - "name": "delete_project_item" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_workflow_run_logs.snap b/pkg/github/__toolsnaps__/delete_workflow_run_logs.snap deleted file mode 100644 index 2e2de73312..0000000000 --- a/pkg/github/__toolsnaps__/delete_workflow_run_logs.snap +++ /dev/null @@ -1,30 +0,0 @@ -{ - "annotations": { - "destructiveHint": true, - "title": "Delete workflow logs" - }, - "description": "Delete logs for a workflow run", - "inputSchema": { - "properties": { - "owner": { - "description": "Repository owner", - "type": "string" - }, - "repo": { - "description": "Repository name", - "type": "string" - }, - "run_id": { - "description": "The unique identifier of the workflow run", - "type": "number" - } - }, - "required": [ - "owner", - "repo", - "run_id" - ], - "type": "object" - }, - "name": "delete_workflow_run_logs" -} \ 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__/download_workflow_run_artifact.snap b/pkg/github/__toolsnaps__/download_workflow_run_artifact.snap deleted file mode 100644 index e831b21d53..0000000000 --- a/pkg/github/__toolsnaps__/download_workflow_run_artifact.snap +++ /dev/null @@ -1,30 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "Download workflow artifact" - }, - "description": "Get download URL for a workflow run artifact", - "inputSchema": { - "properties": { - "artifact_id": { - "description": "The unique identifier of the artifact", - "type": "number" - }, - "owner": { - "description": "Repository owner", - "type": "string" - }, - "repo": { - "description": "Repository name", - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "artifact_id" - ], - "type": "object" - }, - "name": "download_workflow_run_artifact" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_code_quality_finding.snap b/pkg/github/__toolsnaps__/get_code_quality_finding.snap new file mode 100644 index 0000000000..378efe835d --- /dev/null +++ b/pkg/github/__toolsnaps__/get_code_quality_finding.snap @@ -0,0 +1,30 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get code quality finding" + }, + "description": "Get details of a specific code quality finding in a GitHub repository.", + "inputSchema": { + "properties": { + "findingNumber": { + "description": "The number of the finding.", + "type": "number" + }, + "owner": { + "description": "The owner of the repository.", + "type": "string" + }, + "repo": { + "description": "The name of the repository.", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "findingNumber" + ], + "type": "object" + }, + "name": "get_code_quality_finding" +} \ 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..0dcd7343e7 100644 --- a/pkg/github/__toolsnaps__/get_discussion_comments.snap +++ b/pkg/github/__toolsnaps__/get_discussion_comments.snap @@ -7,13 +7,17 @@ "inputSchema": { "properties": { "after": { - "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + "description": "Cursor for pagination. Use the cursor from the previous response.", "type": "string" }, "discussionNumber": { "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_file_blame.snap b/pkg/github/__toolsnaps__/get_file_blame.snap new file mode 100644 index 0000000000..83d09f265c --- /dev/null +++ b/pkg/github/__toolsnaps__/get_file_blame.snap @@ -0,0 +1,54 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get file blame information" + }, + "description": "Get git blame information for a file, showing the commit that last modified each line. Ranges share commit metadata via the top-level 'commits' map keyed by SHA. Use 'start_line'/'end_line' to restrict the result to a window of the file, and 'perPage'/'after' to cursor-page through returned ranges. Matching ranges are capped at 1000; when the cap is hit 'truncated' is set to true and 'total_ranges' reports the pre-cap match count.", + "inputSchema": { + "properties": { + "after": { + "description": "Cursor for pagination. Use the cursor from the previous response.", + "type": "string" + }, + "end_line": { + "description": "Optional 1-based ending line of the window of interest. Must be \u003e= start_line when both are provided.", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "path": { + "description": "Path to the file in the repository, relative to the repository root", + "type": "string" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "ref": { + "description": "Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (HEAD).", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "start_line": { + "description": "Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window.", + "minimum": 1, + "type": "number" + } + }, + "required": [ + "owner", + "repo", + "path" + ], + "type": "object" + }, + "name": "get_file_blame" +} \ No newline at end of file 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 4d7d2573b1..6f287df092 100644 --- a/pkg/github/__toolsnaps__/get_me.snap +++ b/pkg/github/__toolsnaps__/get_me.snap @@ -1,4 +1,13 @@ { + "_meta": { + "ui": { + "resourceUri": "ui://github-mcp-server/get-me", + "visibility": [ + "model", + "app" + ] + } + }, "annotations": { "readOnlyHint": true, "title": "Get my user profile" diff --git a/pkg/github/__toolsnaps__/get_project.snap b/pkg/github/__toolsnaps__/get_project.snap deleted file mode 100644 index 6ff320fe8c..0000000000 --- a/pkg/github/__toolsnaps__/get_project.snap +++ /dev/null @@ -1,34 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "Get project" - }, - "description": "Get Project for a user or org", - "inputSchema": { - "properties": { - "owner": { - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - "type": "string" - }, - "owner_type": { - "description": "Owner type", - "enum": [ - "user", - "org" - ], - "type": "string" - }, - "project_number": { - "description": "The project's number", - "type": "number" - } - }, - "required": [ - "project_number", - "owner_type", - "owner" - ], - "type": "object" - }, - "name": "get_project" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_project_field.snap b/pkg/github/__toolsnaps__/get_project_field.snap deleted file mode 100644 index 9d884a20f9..0000000000 --- a/pkg/github/__toolsnaps__/get_project_field.snap +++ /dev/null @@ -1,39 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "Get project field" - }, - "description": "Get Project field for a user or org", - "inputSchema": { - "properties": { - "field_id": { - "description": "The field's id.", - "type": "number" - }, - "owner": { - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - "type": "string" - }, - "owner_type": { - "description": "Owner type", - "enum": [ - "user", - "org" - ], - "type": "string" - }, - "project_number": { - "description": "The project's number.", - "type": "number" - } - }, - "required": [ - "owner_type", - "owner", - "project_number", - "field_id" - ], - "type": "object" - }, - "name": "get_project_field" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_project_item.snap b/pkg/github/__toolsnaps__/get_project_item.snap deleted file mode 100644 index 202bcc53ea..0000000000 --- a/pkg/github/__toolsnaps__/get_project_item.snap +++ /dev/null @@ -1,46 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "Get project item" - }, - "description": "Get a specific Project item for a user or org", - "inputSchema": { - "properties": { - "fields": { - "description": "Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included.", - "items": { - "type": "string" - }, - "type": "array" - }, - "item_id": { - "description": "The item's ID.", - "type": "number" - }, - "owner": { - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - "type": "string" - }, - "owner_type": { - "description": "Owner type", - "enum": [ - "user", - "org" - ], - "type": "string" - }, - "project_number": { - "description": "The project's number.", - "type": "number" - } - }, - "required": [ - "owner_type", - "owner", - "project_number", - "item_id" - ], - "type": "object" - }, - "name": "get_project_item" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_workflow_run.snap b/pkg/github/__toolsnaps__/get_workflow_run.snap deleted file mode 100644 index e58ea0ba2c..0000000000 --- a/pkg/github/__toolsnaps__/get_workflow_run.snap +++ /dev/null @@ -1,30 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "Get workflow run" - }, - "description": "Get details of a specific workflow run", - "inputSchema": { - "properties": { - "owner": { - "description": "Repository owner", - "type": "string" - }, - "repo": { - "description": "Repository name", - "type": "string" - }, - "run_id": { - "description": "The unique identifier of the workflow run", - "type": "number" - } - }, - "required": [ - "owner", - "repo", - "run_id" - ], - "type": "object" - }, - "name": "get_workflow_run" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_workflow_run_logs.snap b/pkg/github/__toolsnaps__/get_workflow_run_logs.snap deleted file mode 100644 index 8e76fbfc36..0000000000 --- a/pkg/github/__toolsnaps__/get_workflow_run_logs.snap +++ /dev/null @@ -1,30 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "Get workflow run logs" - }, - "description": "Download logs for a specific workflow run (EXPENSIVE: downloads ALL logs as ZIP. Consider using get_job_logs with failed_only=true for debugging failed jobs)", - "inputSchema": { - "properties": { - "owner": { - "description": "Repository owner", - "type": "string" - }, - "repo": { - "description": "Repository name", - "type": "string" - }, - "run_id": { - "description": "The unique identifier of the workflow run", - "type": "number" - } - }, - "required": [ - "owner", - "repo", - "run_id" - ], - "type": "object" - }, - "name": "get_workflow_run_logs" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_workflow_run_usage.snap b/pkg/github/__toolsnaps__/get_workflow_run_usage.snap deleted file mode 100644 index 40069b8366..0000000000 --- a/pkg/github/__toolsnaps__/get_workflow_run_usage.snap +++ /dev/null @@ -1,30 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "Get workflow usage" - }, - "description": "Get usage metrics for a workflow run", - "inputSchema": { - "properties": { - "owner": { - "description": "Repository owner", - "type": "string" - }, - "repo": { - "description": "Repository name", - "type": "string" - }, - "run_id": { - "description": "The unique identifier of the workflow run", - "type": "number" - } - }, - "required": [ - "owner", - "repo", - "run_id" - ], - "type": "object" - }, - "name": "get_workflow_run_usage" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index 4512eb6143..06be3b0278 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -1,6 +1,15 @@ { + "_meta": { + "ui": { + "resourceUri": "ui://github-mcp-server/issue-write", + "visibility": [ + "model", + "app" + ] + } + }, "annotations": { - "title": "Create or update issue." + "title": "Create or update issue/pull request" }, "description": "Create a new or update an existing issue in a GitHub repository.", "inputSchema": { @@ -51,6 +60,10 @@ "description": "Repository name", "type": "string" }, + "show_ui": { + "description": "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action.", + "type": "boolean" + }, "state": { "description": "New state", "enum": [ @@ -73,7 +86,7 @@ "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.", + "description": "Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter.", "type": "string" } }, 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..47d00c4456 --- /dev/null +++ b/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap @@ -0,0 +1,137 @@ +{ + "_meta": { + "ui": { + "resourceUri": "ui://github-mcp-server/issue-write", + "visibility": [ + "model", + "app" + ] + } + }, + "annotations": { + "title": "Create or update issue/pull request" + }, + "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" + }, + "show_ui": { + "description": "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, issue_fields, or state changes) and the user has already confirmed the action.", + "type": "boolean" + }, + "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 issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner 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_commits.snap b/pkg/github/__toolsnaps__/list_commits.snap index 38b63736fa..1a773f217e 100644 --- a/pkg/github/__toolsnaps__/list_commits.snap +++ b/pkg/github/__toolsnaps__/list_commits.snap @@ -19,6 +19,10 @@ "minimum": 1, "type": "number" }, + "path": { + "description": "Only commits containing this file path will be returned", + "type": "string" + }, "perPage": { "description": "Results per page for pagination (min 1, max 100)", "maximum": 100, @@ -32,6 +36,14 @@ "sha": { "description": "Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA.", "type": "string" + }, + "since": { + "description": "Only commits after this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", + "type": "string" + }, + "until": { + "description": "Only commits before this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", + "type": "string" } }, "required": [ diff --git a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap index 83f7259878..5fdbcd2e6f 100644 --- a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap +++ b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap @@ -6,10 +6,20 @@ "description": "List dependabot alerts in a GitHub repository.", "inputSchema": { "properties": { + "after": { + "description": "Cursor for pagination. Use the cursor from the previous response.", + "type": "string" + }, "owner": { "description": "The owner of the repository.", "type": "string" }, + "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_discussions.snap b/pkg/github/__toolsnaps__/list_discussions.snap index 42be769335..fdf5f6d7a3 100644 --- a/pkg/github/__toolsnaps__/list_discussions.snap +++ b/pkg/github/__toolsnaps__/list_discussions.snap @@ -7,7 +7,7 @@ "inputSchema": { "properties": { "after": { - "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + "description": "Cursor for pagination. Use the cursor from the previous response.", "type": "string" }, "category": { 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_issue_types.snap b/pkg/github/__toolsnaps__/list_issue_types.snap index f1f1377a81..283cb5a8de 100644 --- a/pkg/github/__toolsnaps__/list_issue_types.snap +++ b/pkg/github/__toolsnaps__/list_issue_types.snap @@ -3,11 +3,15 @@ "readOnlyHint": true, "title": "List available issue types" }, - "description": "List supported issue types for repository owner (organization).", + "description": "List supported issue types for a repository or its owner organization. When repo is omitted, returns org-level issue types directly.", "inputSchema": { "properties": { "owner": { - "description": "The organization owner of the repository", + "description": "The account owner of the repository or organization.", + "type": "string" + }, + "repo": { + "description": "The name of the repository. When provided, returns issue types for this specific repository. When omitted, returns org-level issue types directly.", "type": "string" } }, diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap index a4be59bb0c..8ce261d7cb 100644 --- a/pkg/github/__toolsnaps__/list_issues.snap +++ b/pkg/github/__toolsnaps__/list_issues.snap @@ -7,7 +7,7 @@ "inputSchema": { "properties": { "after": { - "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + "description": "Cursor for pagination. Use the cursor from the previous response.", "type": "string" }, "direction": { 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..53a951846a --- /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 cursor from the previous response.", + "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_project_fields.snap b/pkg/github/__toolsnaps__/list_project_fields.snap deleted file mode 100644 index 5456388b2a..0000000000 --- a/pkg/github/__toolsnaps__/list_project_fields.snap +++ /dev/null @@ -1,46 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "List project fields" - }, - "description": "List Project fields for a user or org", - "inputSchema": { - "properties": { - "after": { - "description": "Forward pagination cursor from previous pageInfo.nextCursor.", - "type": "string" - }, - "before": { - "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).", - "type": "string" - }, - "owner": { - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - "type": "string" - }, - "owner_type": { - "description": "Owner type", - "enum": [ - "user", - "org" - ], - "type": "string" - }, - "per_page": { - "description": "Results per page (max 50)", - "type": "number" - }, - "project_number": { - "description": "The project's number.", - "type": "number" - } - }, - "required": [ - "owner_type", - "owner", - "project_number" - ], - "type": "object" - }, - "name": "list_project_fields" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_project_items.snap b/pkg/github/__toolsnaps__/list_project_items.snap deleted file mode 100644 index 5089f43067..0000000000 --- a/pkg/github/__toolsnaps__/list_project_items.snap +++ /dev/null @@ -1,57 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "List project items" - }, - "description": "Search project items with advanced filtering", - "inputSchema": { - "properties": { - "after": { - "description": "Forward pagination cursor from previous pageInfo.nextCursor.", - "type": "string" - }, - "before": { - "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).", - "type": "string" - }, - "fields": { - "description": "Field IDs to include (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned.", - "items": { - "type": "string" - }, - "type": "array" - }, - "owner": { - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - "type": "string" - }, - "owner_type": { - "description": "Owner type", - "enum": [ - "user", - "org" - ], - "type": "string" - }, - "per_page": { - "description": "Results per page (max 50)", - "type": "number" - }, - "project_number": { - "description": "The project's number.", - "type": "number" - }, - "query": { - "description": "Query string for advanced filtering of project items using GitHub's project filtering syntax.", - "type": "string" - } - }, - "required": [ - "owner_type", - "owner", - "project_number" - ], - "type": "object" - }, - "name": "list_project_items" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_projects.snap b/pkg/github/__toolsnaps__/list_projects.snap deleted file mode 100644 index be5a6713e6..0000000000 --- a/pkg/github/__toolsnaps__/list_projects.snap +++ /dev/null @@ -1,45 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "List projects" - }, - "description": "List Projects for a user or organization", - "inputSchema": { - "properties": { - "after": { - "description": "Forward pagination cursor from previous pageInfo.nextCursor.", - "type": "string" - }, - "before": { - "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).", - "type": "string" - }, - "owner": { - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - "type": "string" - }, - "owner_type": { - "description": "Owner type", - "enum": [ - "user", - "org" - ], - "type": "string" - }, - "per_page": { - "description": "Results per page (max 50)", - "type": "number" - }, - "query": { - "description": "Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: \"roadmap is:open\", \"is:open feature planning\".", - "type": "string" - } - }, - "required": [ - "owner_type", - "owner" - ], - "type": "object" - }, - "name": "list_projects" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_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__/list_workflow_jobs.snap b/pkg/github/__toolsnaps__/list_workflow_jobs.snap deleted file mode 100644 index d8fed19652..0000000000 --- a/pkg/github/__toolsnaps__/list_workflow_jobs.snap +++ /dev/null @@ -1,49 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "List workflow jobs" - }, - "description": "List jobs for a specific workflow run", - "inputSchema": { - "properties": { - "filter": { - "description": "Filters jobs by their completed_at timestamp", - "enum": [ - "latest", - "all" - ], - "type": "string" - }, - "owner": { - "description": "Repository owner", - "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": "Repository name", - "type": "string" - }, - "run_id": { - "description": "The unique identifier of the workflow run", - "type": "number" - } - }, - "required": [ - "owner", - "repo", - "run_id" - ], - "type": "object" - }, - "name": "list_workflow_jobs" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_workflow_run_artifacts.snap b/pkg/github/__toolsnaps__/list_workflow_run_artifacts.snap deleted file mode 100644 index 664722901e..0000000000 --- a/pkg/github/__toolsnaps__/list_workflow_run_artifacts.snap +++ /dev/null @@ -1,41 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "List workflow artifacts" - }, - "description": "List artifacts for a workflow run", - "inputSchema": { - "properties": { - "owner": { - "description": "Repository owner", - "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": "Repository name", - "type": "string" - }, - "run_id": { - "description": "The unique identifier of the workflow run", - "type": "number" - } - }, - "required": [ - "owner", - "repo", - "run_id" - ], - "type": "object" - }, - "name": "list_workflow_run_artifacts" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_workflow_runs.snap b/pkg/github/__toolsnaps__/list_workflow_runs.snap deleted file mode 100644 index a9a9916c3a..0000000000 --- a/pkg/github/__toolsnaps__/list_workflow_runs.snap +++ /dev/null @@ -1,98 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "List workflow runs" - }, - "description": "List workflow runs for a specific workflow", - "inputSchema": { - "properties": { - "actor": { - "description": "Returns someone's workflow runs. Use the login for the user who created the workflow run.", - "type": "string" - }, - "branch": { - "description": "Returns workflow runs associated with a branch. Use the name of the branch.", - "type": "string" - }, - "event": { - "description": "Returns workflow runs for a specific event type", - "enum": [ - "branch_protection_rule", - "check_run", - "check_suite", - "create", - "delete", - "deployment", - "deployment_status", - "discussion", - "discussion_comment", - "fork", - "gollum", - "issue_comment", - "issues", - "label", - "merge_group", - "milestone", - "page_build", - "public", - "pull_request", - "pull_request_review", - "pull_request_review_comment", - "pull_request_target", - "push", - "registry_package", - "release", - "repository_dispatch", - "schedule", - "status", - "watch", - "workflow_call", - "workflow_dispatch", - "workflow_run" - ], - "type": "string" - }, - "owner": { - "description": "Repository owner", - "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": "Repository name", - "type": "string" - }, - "status": { - "description": "Returns workflow runs with the check run status", - "enum": [ - "queued", - "in_progress", - "completed", - "requested", - "waiting" - ], - "type": "string" - }, - "workflow_id": { - "description": "The workflow ID or workflow file name", - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "workflow_id" - ], - "type": "object" - }, - "name": "list_workflow_runs" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_workflows.snap b/pkg/github/__toolsnaps__/list_workflows.snap deleted file mode 100644 index b0e51e03a0..0000000000 --- a/pkg/github/__toolsnaps__/list_workflows.snap +++ /dev/null @@ -1,36 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "List workflows" - }, - "description": "List workflows in a repository", - "inputSchema": { - "properties": { - "owner": { - "description": "Repository owner", - "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": "Repository name", - "type": "string" - } - }, - "required": [ - "owner", - "repo" - ], - "type": "object" - }, - "name": "list_workflows" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap index cb5013d749..864f61d83f 100644 --- a/pkg/github/__toolsnaps__/projects_get.snap +++ b/pkg/github/__toolsnaps__/projects_get.snap @@ -26,7 +26,8 @@ "enum": [ "get_project", "get_project_field", - "get_project_item" + "get_project_item", + "get_project_status_update" ], "type": "string" }, @@ -45,12 +46,14 @@ "project_number": { "description": "The project's number.", "type": "number" + }, + "status_update_id": { + "description": "The node ID of the project status update. Required for 'get_project_status_update' method.", + "type": "string" } }, "required": [ - "method", - "owner", - "project_number" + "method" ], "type": "object" }, diff --git a/pkg/github/__toolsnaps__/projects_list.snap b/pkg/github/__toolsnaps__/projects_list.snap index f12452b5a2..c2bb0d3f49 100644 --- a/pkg/github/__toolsnaps__/projects_list.snap +++ b/pkg/github/__toolsnaps__/projects_list.snap @@ -26,7 +26,8 @@ "enum": [ "list_projects", "list_project_fields", - "list_project_items" + "list_project_items", + "list_project_status_updates" ], "type": "string" }, @@ -47,7 +48,7 @@ "type": "number" }, "project_number": { - "description": "The project's number. Required for 'list_project_fields' and 'list_project_items' methods.", + "description": "The project's number. Required for 'list_project_fields', 'list_project_items', and 'list_project_status_updates' methods.", "type": "number" }, "query": { diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index d2d871bcd2..6c9d349f63 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -1,11 +1,19 @@ { "annotations": { "destructiveHint": true, - "title": "Modify GitHub Project items" + "title": "Manage GitHub Projects" }, - "description": "Add, update, or delete project items 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" @@ -30,12 +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" + "delete_project_item", + "create_project_status_update", + "create_project", + "create_iteration_field" ], "type": "string" }, @@ -44,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" @@ -52,13 +94,36 @@ "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": { "description": "The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number.", "type": "number" }, + "start_date": { + "description": "Start date in YYYY-MM-DD format. Used for 'create_project_status_update' and 'create_iteration_field' methods.", + "type": "string" + }, + "status": { + "description": "The status of the project. Used for 'create_project_status_update' method.", + "enum": [ + "INACTIVE", + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE" + ], + "type": "string" + }, + "target_date": { + "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" @@ -66,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 a8591fc5c6..f1bb855d51 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -6,16 +6,22 @@ "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 status of a head commit in a pull request. This reflects status of builds and checks.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n", + "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_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned.\n 6. 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 7. 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 8. 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 9. 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", "get_status", "get_files", + "get_commits", "get_review_comments", "get_reviews", - "get_comments" + "get_comments", + "get_check_runs" ], "type": "string" }, diff --git a/pkg/github/__toolsnaps__/pull_request_review_write.snap b/pkg/github/__toolsnaps__/pull_request_review_write.snap index 7b533f4723..d4a7c30d32 100644 --- a/pkg/github/__toolsnaps__/pull_request_review_write.snap +++ b/pkg/github/__toolsnaps__/pull_request_review_write.snap @@ -1,8 +1,8 @@ { "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", + "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": { "properties": { "body": { @@ -27,7 +27,9 @@ "enum": [ "create", "submit_pending", - "delete_pending" + "delete_pending", + "resolve_thread", + "unresolve_thread" ], "type": "string" }, @@ -42,6 +44,10 @@ "repo": { "description": "Repository name", "type": "string" + }, + "threadId": { + "description": "The node ID of the review thread (e.g., PRRT_kwDOxxx). Required for resolve_thread and unresolve_thread methods. Get thread IDs from pull_request_read with method get_review_comments.", + "type": "string" } }, "required": [ 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__/rerun_failed_jobs.snap b/pkg/github/__toolsnaps__/rerun_failed_jobs.snap deleted file mode 100644 index 099c891533..0000000000 --- a/pkg/github/__toolsnaps__/rerun_failed_jobs.snap +++ /dev/null @@ -1,29 +0,0 @@ -{ - "annotations": { - "title": "Rerun failed jobs" - }, - "description": "Re-run only the failed jobs in a workflow run", - "inputSchema": { - "properties": { - "owner": { - "description": "Repository owner", - "type": "string" - }, - "repo": { - "description": "Repository name", - "type": "string" - }, - "run_id": { - "description": "The unique identifier of the workflow run", - "type": "number" - } - }, - "required": [ - "owner", - "repo", - "run_id" - ], - "type": "object" - }, - "name": "rerun_failed_jobs" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/rerun_workflow_run.snap b/pkg/github/__toolsnaps__/rerun_workflow_run.snap deleted file mode 100644 index 946bd72f34..0000000000 --- a/pkg/github/__toolsnaps__/rerun_workflow_run.snap +++ /dev/null @@ -1,29 +0,0 @@ -{ - "annotations": { - "title": "Rerun workflow run" - }, - "description": "Re-run an entire workflow run", - "inputSchema": { - "properties": { - "owner": { - "description": "Repository owner", - "type": "string" - }, - "repo": { - "description": "Repository name", - "type": "string" - }, - "run_id": { - "description": "The unique identifier of the workflow run", - "type": "number" - } - }, - "required": [ - "owner", - "repo", - "run_id" - ], - "type": "object" - }, - "name": "rerun_workflow_run" -} \ 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__/run_workflow.snap b/pkg/github/__toolsnaps__/run_workflow.snap deleted file mode 100644 index 1b6c8993e4..0000000000 --- a/pkg/github/__toolsnaps__/run_workflow.snap +++ /dev/null @@ -1,38 +0,0 @@ -{ - "annotations": { - "title": "Run workflow" - }, - "description": "Run an Actions workflow by workflow ID or filename", - "inputSchema": { - "properties": { - "inputs": { - "description": "Inputs the workflow accepts", - "type": "object" - }, - "owner": { - "description": "Repository owner", - "type": "string" - }, - "ref": { - "description": "The git reference for the workflow. The reference can be a branch or tag name.", - "type": "string" - }, - "repo": { - "description": "Repository name", - "type": "string" - }, - "workflow_id": { - "description": "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml)", - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "workflow_id", - "ref" - ], - "type": "object" - }, - "name": "run_workflow" -} \ 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..7a98fde2aa --- /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.", + "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__/ui_get.snap b/pkg/github/__toolsnaps__/ui_get.snap new file mode 100644 index 0000000000..7f13d97c1c --- /dev/null +++ b/pkg/github/__toolsnaps__/ui_get.snap @@ -0,0 +1,45 @@ +{ + "_meta": { + "ui": { + "visibility": [ + "app" + ] + } + }, + "annotations": { + "readOnlyHint": true, + "title": "Get UI data" + }, + "description": "Fetch UI data for MCP Apps (labels, assignees, milestones, issue types, branches, issue fields, reviewers).", + "inputSchema": { + "properties": { + "method": { + "description": "The type of data to fetch", + "enum": [ + "labels", + "assignees", + "milestones", + "issue_types", + "branches", + "issue_fields", + "reviewers" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner (required for all methods)", + "type": "string" + }, + "repo": { + "description": "Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers)", + "type": "string" + } + }, + "required": [ + "method", + "owner" + ], + "type": "object" + }, + "name": "ui_get" +} \ 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..2b31d756b0 --- /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..d07a9d43d9 --- /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_project_item.snap b/pkg/github/__toolsnaps__/update_project_item.snap deleted file mode 100644 index 9875907416..0000000000 --- a/pkg/github/__toolsnaps__/update_project_item.snap +++ /dev/null @@ -1,43 +0,0 @@ -{ - "annotations": { - "title": "Update project item" - }, - "description": "Update a specific Project item for a user or org", - "inputSchema": { - "properties": { - "item_id": { - "description": "The unique identifier of the project item. This is not the issue or pull request ID.", - "type": "number" - }, - "owner": { - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - "type": "string" - }, - "owner_type": { - "description": "Owner type", - "enum": [ - "user", - "org" - ], - "type": "string" - }, - "project_number": { - "description": "The project's number.", - "type": "number" - }, - "updated_field": { - "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}", - "type": "object" - } - }, - "required": [ - "owner_type", - "owner", - "project_number", - "item_id", - "updated_field" - ], - "type": "object" - }, - "name": "update_project_item" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_pull_request.snap b/pkg/github/__toolsnaps__/update_pull_request.snap index ef330188ff..cadc391ef4 100644 --- a/pkg/github/__toolsnaps__/update_pull_request.snap +++ b/pkg/github/__toolsnaps__/update_pull_request.snap @@ -1,4 +1,13 @@ { + "_meta": { + "ui": { + "resourceUri": "ui://github-mcp-server/pr-edit", + "visibility": [ + "model", + "app" + ] + } + }, "annotations": { "title": "Edit pull request" }, @@ -34,7 +43,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 d3e5aad8eb..9dac877736 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -12,11 +12,12 @@ import ( "github.com/github/github-mcp-server/internal/profiler" buffer "github.com/github/github-mcp-server/pkg/buffer" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/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/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -26,10 +27,6 @@ const ( DescriptionRepositoryName = "Repository name" ) -// FeatureFlagHoldbackConsolidatedActions is the feature flag that, when enabled, reverts to -// individual actions tools instead of the consolidated actions tools. -const FeatureFlagHoldbackConsolidatedActions = "mcp_holdback_consolidated_actions" - // Method constants for consolidated actions tools const ( actionsMethodListWorkflows = "list_workflows" @@ -49,725 +46,24 @@ const ( actionsMethodDeleteWorkflowRunLogs = "delete_workflow_run_logs" ) -// ListWorkflows creates a tool to list workflows in a repository -func ListWorkflows(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "list_workflows", - Description: t("TOOL_LIST_WORKFLOWS_DESCRIPTION", "List workflows in a repository"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_WORKFLOWS_USER_TITLE", "List workflows"), - ReadOnlyHint: true, - }, - InputSchema: WithPagination(&jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - }, - Required: []string{"owner", "repo"}, - }), - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), 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 - } - - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - // Set up list options - opts := &github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - } - - workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts) - if err != nil { - return nil, nil, fmt.Errorf("failed to list workflows: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(workflows) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions - return tool -} - -// ListWorkflowRuns creates a tool to list workflow runs for a specific workflow -func ListWorkflowRuns(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "list_workflow_runs", - Description: t("TOOL_LIST_WORKFLOW_RUNS_DESCRIPTION", "List workflow runs for a specific workflow"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_WORKFLOW_RUNS_USER_TITLE", "List workflow runs"), - ReadOnlyHint: true, - }, - InputSchema: WithPagination(&jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "workflow_id": { - Type: "string", - Description: "The workflow ID or workflow file name", - }, - "actor": { - Type: "string", - Description: "Returns someone's workflow runs. Use the login for the user who created the workflow run.", - }, - "branch": { - Type: "string", - Description: "Returns workflow runs associated with a branch. Use the name of the branch.", - }, - "event": { - Type: "string", - Description: "Returns workflow runs for a specific event type", - Enum: []any{ - "branch_protection_rule", - "check_run", - "check_suite", - "create", - "delete", - "deployment", - "deployment_status", - "discussion", - "discussion_comment", - "fork", - "gollum", - "issue_comment", - "issues", - "label", - "merge_group", - "milestone", - "page_build", - "public", - "pull_request", - "pull_request_review", - "pull_request_review_comment", - "pull_request_target", - "push", - "registry_package", - "release", - "repository_dispatch", - "schedule", - "status", - "watch", - "workflow_call", - "workflow_dispatch", - "workflow_run", - }, - }, - "status": { - Type: "string", - Description: "Returns workflow runs with the check run status", - Enum: []any{"queued", "in_progress", "completed", "requested", "waiting"}, - }, - }, - Required: []string{"owner", "repo", "workflow_id"}, - }), - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), 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 - } - workflowID, err := RequiredParam[string](args, "workflow_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - // Get optional filtering parameters - actor, err := OptionalParam[string](args, "actor") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - branch, err := OptionalParam[string](args, "branch") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - event, err := OptionalParam[string](args, "event") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - status, err := OptionalParam[string](args, "status") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - // Set up list options - opts := &github.ListWorkflowRunsOptions{ - Actor: actor, - Branch: branch, - Event: event, - Status: status, - ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - }, - } - - workflowRuns, resp, err := client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, workflowID, opts) - if err != nil { - return nil, nil, fmt.Errorf("failed to list workflow runs: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(workflowRuns) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions - return tool -} - -// RunWorkflow creates a tool to run an Actions workflow -func RunWorkflow(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "run_workflow", - Description: t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow by workflow ID or filename"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_RUN_WORKFLOW_USER_TITLE", "Run workflow"), - ReadOnlyHint: false, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "workflow_id": { - Type: "string", - Description: "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml)", - }, - "ref": { - Type: "string", - Description: "The git reference for the workflow. The reference can be a branch or tag name.", - }, - "inputs": { - Type: "object", - Description: "Inputs the workflow accepts", - }, - }, - Required: []string{"owner", "repo", "workflow_id", "ref"}, - }, - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), 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 - } - workflowID, err := RequiredParam[string](args, "workflow_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - ref, err := RequiredParam[string](args, "ref") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - // Get optional inputs parameter - var inputs map[string]interface{} - if requestInputs, ok := args["inputs"]; ok { - if inputsMap, ok := requestInputs.(map[string]interface{}); ok { - inputs = inputsMap - } - } - - event := github.CreateWorkflowDispatchEventRequest{ - Ref: ref, - Inputs: inputs, - } - - var resp *github.Response - var workflowType string - - if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { - resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) - workflowType = "workflow_id" - } else { - resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) - workflowType = "workflow_file" - } - - if err != nil { - return nil, nil, fmt.Errorf("failed to run workflow: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "message": "Workflow run has been queued", - "workflow_type": workflowType, - "workflow_id": workflowID, - "ref": ref, - "inputs": inputs, - "status": resp.Status, - "status_code": resp.StatusCode, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions - return tool -} - -// GetWorkflowRun creates a tool to get details of a specific workflow run -func GetWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "get_workflow_run", - Description: t("TOOL_GET_WORKFLOW_RUN_DESCRIPTION", "Get details of a specific workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_WORKFLOW_RUN_USER_TITLE", "Get workflow run"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", - }, - }, - Required: []string{"owner", "repo", "run_id"}, - }, - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), 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 - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) +// handleFailedJobLogs gets logs for all failed jobs in a workflow run +func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, any, error) { + // First, get all jobs for the workflow run + jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ + Filter: "latest", + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() - workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, runID) - if err != nil { - return nil, nil, fmt.Errorf("failed to get workflow run: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(workflowRun) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions - return tool -} - -// GetWorkflowRunLogs creates a tool to download logs for a specific workflow run -func GetWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "get_workflow_run_logs", - Description: t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run (EXPENSIVE: downloads ALL logs as ZIP. Consider using get_job_logs with failed_only=true for debugging failed jobs)"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_WORKFLOW_RUN_LOGS_USER_TITLE", "Get workflow run logs"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", - }, - }, - Required: []string{"owner", "repo", "run_id"}, - }, - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), 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 - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) - - // Get the download URL for the logs - url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1) - if err != nil { - return nil, nil, fmt.Errorf("failed to get workflow run logs: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - // Create response with the logs URL and information - result := map[string]any{ - "logs_url": url.String(), - "message": "Workflow run logs are available for download", - "note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.", - "warning": "This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead.", - "optimization_tip": "Use: get_job_logs with parameters {run_id: " + fmt.Sprintf("%d", runID) + ", failed_only: true} for more efficient failed job debugging", - } - - r, err := json.Marshal(result) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions - return tool -} - -// ListWorkflowJobs creates a tool to list jobs for a specific workflow run -func ListWorkflowJobs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "list_workflow_jobs", - Description: t("TOOL_LIST_WORKFLOW_JOBS_DESCRIPTION", "List jobs for a specific workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_WORKFLOW_JOBS_USER_TITLE", "List workflow jobs"), - ReadOnlyHint: true, - }, - InputSchema: WithPagination(&jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", - }, - "filter": { - Type: "string", - Description: "Filters jobs by their completed_at timestamp", - Enum: []any{"latest", "all"}, - }, - }, - Required: []string{"owner", "repo", "run_id"}, - }), - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), 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 - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) - - // Get optional filtering parameters - filter, err := OptionalParam[string](args, "filter") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - // Set up list options - opts := &github.ListWorkflowJobsOptions{ - Filter: filter, - ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - }, - } - - jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, opts) - if err != nil { - return nil, nil, fmt.Errorf("failed to list workflow jobs: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - // Add optimization tip for failed job debugging - response := map[string]any{ - "jobs": jobs, - "optimization_tip": "For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id=" + fmt.Sprintf("%d", runID) + " to get logs directly without needing to list jobs first", - } - - r, err := json.Marshal(response) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions - return tool -} - -// GetJobLogs creates a tool to download logs for a specific workflow job or efficiently get all failed job logs for a workflow run -func GetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "get_job_logs", - Description: t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_JOB_LOGS_USER_TITLE", "Get job logs"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "job_id": { - Type: "number", - Description: "The unique identifier of the workflow job (required for single job logs)", - }, - "run_id": { - Type: "number", - Description: "Workflow run ID (required when using failed_only)", - }, - "failed_only": { - Type: "boolean", - Description: "When true, gets logs for all failed jobs in run_id", - }, - "return_content": { - Type: "boolean", - Description: "Returns actual log content instead of URLs", - }, - "tail_lines": { - Type: "number", - Description: "Number of lines to return from the end of the log", - Default: json.RawMessage(`500`), - }, - }, - Required: []string{"owner", "repo"}, - }, - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), 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 - } - - // Get optional parameters - jobID, err := OptionalIntParam(args, "job_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID, err := OptionalIntParam(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - failedOnly, err := OptionalParam[bool](args, "failed_only") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - returnContent, err := OptionalParam[bool](args, "return_content") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - tailLines, err := OptionalIntParam(args, "tail_lines") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - // Default to 500 lines if not specified - if tailLines == 0 { - tailLines = 500 - } - - // Validate parameters - if failedOnly && runID == 0 { - return utils.NewToolResultError("run_id is required when failed_only is true"), nil, nil - } - if !failedOnly && jobID == 0 { - return utils.NewToolResultError("job_id is required when failed_only is false"), nil, nil - } - - if failedOnly && runID > 0 { - // Handle failed-only mode: get logs for all failed jobs in the workflow run - return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize()) - } else if jobID > 0 { - // Handle single job mode - return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, deps.GetContentWindowSize()) - } - - return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions - return tool -} - -// handleFailedJobLogs gets logs for all failed jobs in a workflow run -func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, any, error) { - // First, get all jobs for the workflow run - jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ - Filter: "latest", - }) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - // Filter for failed jobs - var failedJobs []*github.WorkflowJob - for _, job := range jobs.Jobs { - if job.GetConclusion() == "failure" { - failedJobs = append(failedJobs, job) - } - } + // Filter for failed jobs + var failedJobs []*github.WorkflowJob + for _, job := range jobs.Jobs { + if job.GetConclusion() == "failure" { + failedJobs = append(failedJobs, job) + } + } if len(failedJobs) == 0 { result := map[string]any{ @@ -775,668 +71,130 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo "run_id": runID, "total_jobs": len(jobs.Jobs), "failed_jobs": 0, - } - r, _ := json.Marshal(result) - return utils.NewToolResultText(string(r)), nil, nil - } - - // Collect logs for all failed jobs - var logResults []map[string]any - for _, job := range failedJobs { - jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines, contentWindowSize) - if err != nil { - // Continue with other jobs even if one fails - jobResult = map[string]any{ - "job_id": job.GetID(), - "job_name": job.GetName(), - "error": err.Error(), - } - // Enable reporting of status codes and error causes - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err) // Explicitly ignore error for graceful handling - } - - logResults = append(logResults, jobResult) - } - - result := map[string]any{ - "message": fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)), - "run_id": runID, - "total_jobs": len(jobs.Jobs), - "failed_jobs": len(failedJobs), - "logs": logResults, - "return_format": map[string]bool{"content": returnContent, "urls": !returnContent}, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil -} - -// handleSingleJobLogs gets logs for a single job -func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, any, error) { - jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines, contentWindowSize) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil, nil - } - - r, err := json.Marshal(jobResult) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil -} - -// getJobLogData retrieves log data for a single job, either as URL or content -func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int, contentWindowSize int) (map[string]any, *github.Response, error) { - // Get the download URL for the job logs - url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) - if err != nil { - return nil, resp, fmt.Errorf("failed to get job logs for job %d: %w", jobID, err) - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "job_id": jobID, - } - if jobName != "" { - result["job_name"] = jobName - } - - if returnContent { - // Download and return the actual log content - content, originalLength, httpResp, err := downloadLogContent(ctx, url.String(), tailLines, contentWindowSize) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp - if err != nil { - // To keep the return value consistent wrap the response as a GitHub Response - ghRes := &github.Response{ - Response: httpResp, - } - return nil, ghRes, fmt.Errorf("failed to download log content for job %d: %w", jobID, err) - } - result["logs_content"] = content - result["message"] = "Job logs content retrieved successfully" - result["original_length"] = originalLength - } else { - // Return just the URL - result["logs_url"] = url.String() - result["message"] = "Job logs are available for download" - result["note"] = "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content." - } - - return result, resp, nil -} - -func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLines int) (string, int, *http.Response, error) { - prof := profiler.New(nil, profiler.IsProfilingEnabled()) - finish := prof.Start(ctx, "log_buffer_processing") - - httpResp, err := http.Get(logURL) //nolint:gosec - if err != nil { - return "", 0, httpResp, fmt.Errorf("failed to download logs: %w", err) - } - defer func() { _ = httpResp.Body.Close() }() - - if httpResp.StatusCode != http.StatusOK { - return "", 0, httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode) - } - - bufferSize := tailLines - if bufferSize > maxLines { - bufferSize = maxLines - } - - processedInput, totalLines, httpResp, err := buffer.ProcessResponseAsRingBufferToEnd(httpResp, bufferSize) - if err != nil { - return "", 0, httpResp, fmt.Errorf("failed to process log content: %w", err) - } - - lines := strings.Split(processedInput, "\n") - if len(lines) > tailLines { - lines = lines[len(lines)-tailLines:] - } - finalResult := strings.Join(lines, "\n") - - _ = finish(len(lines), int64(len(finalResult))) - - return finalResult, totalLines, httpResp, nil -} - -// RerunWorkflowRun creates a tool to re-run an entire workflow run -func RerunWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "rerun_workflow_run", - Description: t("TOOL_RERUN_WORKFLOW_RUN_DESCRIPTION", "Re-run an entire workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_RERUN_WORKFLOW_RUN_USER_TITLE", "Rerun workflow run"), - ReadOnlyHint: false, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", - }, - }, - Required: []string{"owner", "repo", "run_id"}, - }, - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), 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 - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) - - resp, err := client.Actions.RerunWorkflowByID(ctx, owner, repo, runID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun workflow run", resp, err), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "message": "Workflow run has been queued for re-run", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions - return tool -} - -// RerunFailedJobs creates a tool to re-run only the failed jobs in a workflow run -func RerunFailedJobs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "rerun_failed_jobs", - Description: t("TOOL_RERUN_FAILED_JOBS_DESCRIPTION", "Re-run only the failed jobs in a workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_RERUN_FAILED_JOBS_USER_TITLE", "Rerun failed jobs"), - ReadOnlyHint: false, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", - }, - }, - Required: []string{"owner", "repo", "run_id"}, - }, - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), 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 - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) - - resp, err := client.Actions.RerunFailedJobsByID(ctx, owner, repo, runID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun failed jobs", resp, err), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "message": "Failed jobs have been queued for re-run", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions - return tool -} - -// CancelWorkflowRun creates a tool to cancel a workflow run -func CancelWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "cancel_workflow_run", - Description: t("TOOL_CANCEL_WORKFLOW_RUN_DESCRIPTION", "Cancel a workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_CANCEL_WORKFLOW_RUN_USER_TITLE", "Cancel workflow run"), - ReadOnlyHint: false, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", - }, - }, - Required: []string{"owner", "repo", "run_id"}, - }, - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), 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 - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) - - resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) - if err != nil { - if _, ok := err.(*github.AcceptedError); !ok { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), nil, nil - } - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "message": "Workflow run has been cancelled", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions - return tool -} - -// ListWorkflowRunArtifacts creates a tool to list artifacts for a workflow run -func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "list_workflow_run_artifacts", - Description: t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_DESCRIPTION", "List artifacts for a workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_USER_TITLE", "List workflow artifacts"), - ReadOnlyHint: true, - }, - InputSchema: WithPagination(&jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", - }, - }, - Required: []string{"owner", "repo", "run_id"}, - }), - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), 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 - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) + } + r, _ := json.Marshal(result) + return utils.NewToolResultText(string(r)), nil, nil + } - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + // Collect logs for all failed jobs + var logResults []map[string]any + for _, job := range failedJobs { + jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines, contentWindowSize) + if err != nil { + // Continue with other jobs even if one fails + jobResult = map[string]any{ + "job_id": job.GetID(), + "job_name": job.GetName(), + "error": err.Error(), } + // Enable reporting of status codes and error causes + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err) // Explicitly ignore error for graceful handling + } - // Set up list options - opts := &github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - } + logResults = append(logResults, jobResult) + } - artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, runID, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow run artifacts", resp, err), nil, nil - } - defer func() { _ = resp.Body.Close() }() + result := map[string]any{ + "message": fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)), + "run_id": runID, + "total_jobs": len(jobs.Jobs), + "failed_jobs": len(failedJobs), + "logs": logResults, + "return_format": map[string]bool{"content": returnContent, "urls": !returnContent}, + } - r, err := json.Marshal(artifacts) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions - return tool + return utils.NewToolResultText(string(r)), nil, nil } -// DownloadWorkflowRunArtifact creates a tool to download a workflow run artifact -func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "download_workflow_run_artifact", - Description: t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_DESCRIPTION", "Get download URL for a workflow run artifact"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_USER_TITLE", "Download workflow artifact"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "artifact_id": { - Type: "number", - Description: "The unique identifier of the artifact", - }, - }, - Required: []string{"owner", "repo", "artifact_id"}, - }, - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), 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 - } - artifactIDInt, err := RequiredInt(args, "artifact_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - artifactID := int64(artifactIDInt) - - // Get the download URL for the artifact - url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, artifactID, 1) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get artifact download URL", resp, err), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - // Create response with the download URL and information - result := map[string]any{ - "download_url": url.String(), - "message": "Artifact is available for download", - "note": "The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time.", - "artifact_id": artifactID, - } +// handleSingleJobLogs gets logs for a single job +func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, any, error) { + jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines, contentWindowSize) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil, nil + } - r, err := json.Marshal(result) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } + r, err := json.Marshal(jobResult) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions - return tool + return utils.NewToolResultText(string(r)), nil, nil } -// DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run -func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "delete_workflow_run_logs", - Description: t("TOOL_DELETE_WORKFLOW_RUN_LOGS_DESCRIPTION", "Delete logs for a workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_DELETE_WORKFLOW_RUN_LOGS_USER_TITLE", "Delete workflow logs"), - ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", - }, - }, - Required: []string{"owner", "repo", "run_id"}, - }, - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } +// getJobLogData retrieves log data for a single job, either as URL or content +func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int, contentWindowSize int) (map[string]any, *github.Response, error) { + // Get the download URL for the job logs + url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) + if err != nil { + return nil, resp, fmt.Errorf("failed to get job logs for job %d: %w", jobID, err) + } + defer func() { _ = resp.Body.Close() }() - 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 - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) + result := map[string]any{ + "job_id": jobID, + } + if jobName != "" { + result["job_name"] = jobName + } - resp, err := client.Actions.DeleteWorkflowRunLogs(ctx, owner, repo, runID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to delete workflow run logs", resp, err), nil, nil + if returnContent { + // Download and return the actual log content + content, originalLength, httpResp, err := downloadLogContent(ctx, url.String(), tailLines, contentWindowSize) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp + if err != nil { + // To keep the return value consistent wrap the response as a GitHub Response + ghRes := &github.Response{ + Response: httpResp, } - defer func() { _ = resp.Body.Close() }() + return nil, ghRes, fmt.Errorf("failed to download log content for job %d: %w", jobID, err) + } + result["logs_content"] = content + result["message"] = "Job logs content retrieved successfully" + result["original_length"] = originalLength + } else { + // Return just the URL + result["logs_url"] = url.String() + result["message"] = "Job logs are available for download" + result["note"] = "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content." + } - result := map[string]any{ - "message": "Workflow run logs have been deleted", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, - } + return result, resp, nil +} - r, err := json.Marshal(result) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } +func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLines int) (string, int, *http.Response, error) { + prof := profiler.New(nil, profiler.IsProfilingEnabled()) + finish := prof.Start(ctx, "log_buffer_processing") - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions - return tool -} + httpResp, err := http.Get(logURL) //nolint:gosec + if err != nil { + return "", 0, httpResp, fmt.Errorf("failed to download logs: %w", err) + } + defer func() { _ = httpResp.Body.Close() }() -// GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run -func GetWorkflowRunUsage(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataActions, - mcp.Tool{ - Name: "get_workflow_run_usage", - Description: t("TOOL_GET_WORKFLOW_RUN_USAGE_DESCRIPTION", "Get usage metrics for a workflow run"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_WORKFLOW_RUN_USAGE_USER_TITLE", "Get workflow usage"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: DescriptionRepositoryOwner, - }, - "repo": { - Type: "string", - Description: DescriptionRepositoryName, - }, - "run_id": { - Type: "number", - Description: "The unique identifier of the workflow run", - }, - }, - Required: []string{"owner", "repo", "run_id"}, - }, - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } + if httpResp.StatusCode != http.StatusOK { + return "", 0, httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode) + } - 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 - } - runIDInt, err := RequiredInt(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - runID := int64(runIDInt) + bufferSize := min(tailLines, maxLines) - usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, runID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run usage", resp, err), nil, nil - } - defer func() { _ = resp.Body.Close() }() + processedInput, totalLines, httpResp, err := buffer.ProcessResponseAsRingBufferToEnd(httpResp, bufferSize) + if err != nil { + return "", 0, httpResp, fmt.Errorf("failed to process log content: %w", err) + } - r, err := json.Marshal(usage) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } + lines := strings.Split(processedInput, "\n") + if len(lines) > tailLines { + lines = lines[len(lines)-tailLines:] + } + finalResult := strings.Join(lines, "\n") - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedActions - return tool + _ = finish(len(lines), int64(len(finalResult))) + + return finalResult, totalLines, httpResp, nil } // ActionsList returns the tool and handler for listing GitHub Actions resources. @@ -1597,6 +355,14 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } + // attachIFC adds the IFC label to a successful Actions result when + // IFC labels are enabled. Workflow definitions, runs, jobs, + // artifacts and logs echo attacker-influenceable run output, so + // integrity is untrusted; confidentiality follows repo visibility. + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelActionsResult) + } + var resourceIDInt int64 var parseErr error switch method { @@ -1619,19 +385,22 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an switch method { case actionsMethodListWorkflows: - return listWorkflows(ctx, client, owner, repo, pagination) + result, payload, err := listWorkflows(ctx, client, owner, repo, pagination) + return attachIFC(result), payload, err case actionsMethodListWorkflowRuns: - return listWorkflowRuns(ctx, client, args, owner, repo, resourceID, pagination) + result, payload, err := listWorkflowRuns(ctx, client, args, owner, repo, resourceID, pagination) + return attachIFC(result), payload, err case actionsMethodListWorkflowJobs: - return listWorkflowJobs(ctx, client, args, owner, repo, resourceIDInt, pagination) + result, payload, err := listWorkflowJobs(ctx, client, args, owner, repo, resourceIDInt, pagination) + return attachIFC(result), payload, err case actionsMethodListWorkflowArtifacts: - return listWorkflowArtifacts(ctx, client, owner, repo, resourceIDInt, pagination) + result, payload, err := listWorkflowArtifacts(ctx, client, owner, repo, resourceIDInt, pagination) + return attachIFC(result), payload, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, ) - tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedActions return tool } @@ -1709,6 +478,14 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } + // attachIFC adds the IFC label to a successful Actions result when + // IFC labels are enabled. Workflow runs, jobs, artifacts, usage, + // and log URLs reflect attacker-influenceable run output, so + // integrity is untrusted; confidentiality follows repo visibility. + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelActionsResult) + } + var resourceIDInt int64 var parseErr error switch method { @@ -1724,23 +501,28 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an switch method { case actionsMethodGetWorkflow: - return getWorkflow(ctx, client, owner, repo, resourceID) + result, payload, err := getWorkflow(ctx, client, owner, repo, resourceID) + return attachIFC(result), payload, err case actionsMethodGetWorkflowRun: - return getWorkflowRun(ctx, client, owner, repo, resourceIDInt) + result, payload, err := getWorkflowRun(ctx, client, owner, repo, resourceIDInt) + return attachIFC(result), payload, err case actionsMethodGetWorkflowJob: - return getWorkflowJob(ctx, client, owner, repo, resourceIDInt) + result, payload, err := getWorkflowJob(ctx, client, owner, repo, resourceIDInt) + return attachIFC(result), payload, err case actionsMethodDownloadWorkflowArtifact: - return downloadWorkflowArtifact(ctx, client, owner, repo, resourceIDInt) + result, payload, err := downloadWorkflowArtifact(ctx, client, owner, repo, resourceIDInt) + return attachIFC(result), payload, err case actionsMethodGetWorkflowRunUsage: - return getWorkflowRunUsage(ctx, client, owner, repo, resourceIDInt) + result, payload, err := getWorkflowRunUsage(ctx, client, owner, repo, resourceIDInt) + return attachIFC(result), payload, err case actionsMethodGetWorkflowRunLogsURL: - return getWorkflowRunLogsURL(ctx, client, owner, repo, resourceIDInt) + result, payload, err := getWorkflowRunLogsURL(ctx, client, owner, repo, resourceIDInt) + return attachIFC(result), payload, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, ) - tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedActions return tool } @@ -1789,6 +571,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", @@ -1819,11 +602,9 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo runID, _ := OptionalIntParam(args, "run_id") // Get optional inputs parameter - var inputs map[string]interface{} - if requestInputs, ok := args["inputs"]; ok { - if inputsMap, ok := requestInputs.(map[string]interface{}); 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 @@ -1859,7 +640,6 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo } }, ) - tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedActions return tool } @@ -1948,8 +728,8 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - // Default to 500 lines if not specified - if tailLines == 0 { + // Default to 500 lines if not specified or invalid + if tailLines <= 0 { tailLines = 500 } @@ -1966,18 +746,27 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i return utils.NewToolResultError("job_id is required when failed_only is false"), nil, nil } + // attachIFC adds the IFC label to a successful result when IFC + // labels are enabled. Job logs echo attacker-influenceable run + // output, so integrity is untrusted; confidentiality follows repo + // visibility. + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelActionsResult) + } + if failedOnly && runID > 0 { // Handle failed-only mode: get logs for all failed jobs in the workflow run - return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize()) + result, payload, err := handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize()) + return attachIFC(result), payload, err } else if jobID > 0 { // Handle single job mode - return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, deps.GetContentWindowSize()) + result, payload, err := handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, deps.GetContentWindowSize()) + return attachIFC(result), payload, err } return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil }, ) - tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedActions return tool } @@ -2226,7 +1015,7 @@ func getWorkflowRunUsage(ctx context.Context, client *github.Client, owner, repo return utils.NewToolResultText(string(r)), nil, nil } -func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workflowID, ref string, inputs map[string]interface{}) (*mcp.CallToolResult, any, error) { +func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workflowID, ref string, inputs map[string]any) (*mcp.CallToolResult, any, error) { event := github.CreateWorkflowDispatchEventRequest{ Ref: ref, Inputs: inputs, @@ -2237,10 +1026,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 0d47236f66..371bbbe9dc 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -3,1823 +3,17 @@ package github import ( "context" "encoding/json" - "io" "net/http" - "net/http/httptest" - "os" - "runtime" - "runtime/debug" - "strings" "testing" - "github.com/github/github-mcp-server/internal/profiler" "github.com/github/github-mcp-server/internal/toolsnaps" - buffer "github.com/github/github-mcp-server/pkg/buffer" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func Test_ListWorkflows(t *testing.T) { - // Verify tool definition once - toolDef := ListWorkflows(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "list_workflows", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "perPage") - assert.Contains(t, inputSchema.Properties, "page") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow listing", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsWorkflowsByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - workflows := &github.Workflows{ - TotalCount: github.Ptr(2), - Workflows: []*github.Workflow{ - { - ID: github.Ptr(int64(123)), - Name: github.Ptr("CI"), - Path: github.Ptr(".github/workflows/ci.yml"), - State: github.Ptr("active"), - CreatedAt: &github.Timestamp{}, - UpdatedAt: &github.Timestamp{}, - URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/workflows/123"), - HTMLURL: github.Ptr("https://github.com/owner/repo/actions/workflows/ci.yml"), - BadgeURL: github.Ptr("https://github.com/owner/repo/workflows/CI/badge.svg"), - NodeID: github.Ptr("W_123"), - }, - { - ID: github.Ptr(int64(456)), - Name: github.Ptr("Deploy"), - Path: github.Ptr(".github/workflows/deploy.yml"), - State: github.Ptr("active"), - CreatedAt: &github.Timestamp{}, - UpdatedAt: &github.Timestamp{}, - URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/workflows/456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/actions/workflows/deploy.yml"), - BadgeURL: github.Ptr("https://github.com/owner/repo/workflows/Deploy/badge.svg"), - NodeID: github.Ptr("W_456"), - }, - }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(workflows) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: false, - }, - { - name: "missing required parameter owner", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: owner", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - var response github.Workflows - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.NotNil(t, response.TotalCount) - assert.Greater(t, *response.TotalCount, 0) - assert.NotEmpty(t, response.Workflows) - }) - } -} - -func Test_RunWorkflow(t *testing.T) { - // Verify tool definition once - toolDef := RunWorkflow(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "run_workflow", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "workflow_id") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "ref") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "inputs") - assert.ElementsMatch(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "workflow_id", "ref"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow run", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "workflow_id": "12345", - "ref": "main", - }, - expectError: false, - }, - { - name: "missing required parameter workflow_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "ref": "main", - }, - expectError: true, - expectedErrMsg: "missing required parameter: workflow_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Equal(t, "Workflow run has been queued", response["message"]) - assert.Contains(t, response, "workflow_type") - }) - } -} - -func Test_RunWorkflow_WithFilename(t *testing.T) { - // Test the unified RunWorkflow function with filenames - toolDef := RunWorkflow(translations.NullTranslationHelper) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow run by filename", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "workflow_id": "ci.yml", - "ref": "main", - }, - expectError: false, - }, - { - name: "successful workflow run by numeric ID as string", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "workflow_id": "12345", - "ref": "main", - }, - expectError: false, - }, - { - name: "missing required parameter workflow_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "ref": "main", - }, - expectError: true, - expectedErrMsg: "missing required parameter: workflow_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Equal(t, "Workflow run has been queued", response["message"]) - assert.Contains(t, response, "workflow_type") - }) - } -} - -func Test_CancelWorkflowRun(t *testing.T) { - // Verify tool definition once - toolDef := CancelWorkflowRun(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "cancel_workflow_run", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "run_id") - assert.ElementsMatch(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "run_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow run cancellation", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "POST /repos/owner/repo/actions/runs/12345/cancel": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusAccepted) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "conflict when cancelling a workflow run", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "POST /repos/owner/repo/actions/runs/12345/cancel": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusConflict) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: true, - expectedErrMsg: "failed to cancel workflow run", - }, - { - name: "missing required parameter run_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Contains(t, textContent.Text, tc.expectedErrMsg) - return - } - - // Unmarshal and verify the result - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Equal(t, "Workflow run has been cancelled", response["message"]) - assert.Equal(t, float64(12345), response["run_id"]) - }) - } -} - -func Test_ListWorkflowRunArtifacts(t *testing.T) { - // Verify tool definition once - toolDef := ListWorkflowRunArtifacts(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "list_workflow_run_artifacts", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "run_id") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "perPage") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "page") - assert.ElementsMatch(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "run_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful artifacts listing", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsRunsArtifactsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - artifacts := &github.ArtifactList{ - TotalCount: github.Ptr(int64(2)), - Artifacts: []*github.Artifact{ - { - ID: github.Ptr(int64(1)), - NodeID: github.Ptr("A_1"), - Name: github.Ptr("build-artifacts"), - SizeInBytes: github.Ptr(int64(1024)), - URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1"), - ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1/zip"), - Expired: github.Ptr(false), - CreatedAt: &github.Timestamp{}, - UpdatedAt: &github.Timestamp{}, - ExpiresAt: &github.Timestamp{}, - WorkflowRun: &github.ArtifactWorkflowRun{ - ID: github.Ptr(int64(12345)), - RepositoryID: github.Ptr(int64(1)), - HeadRepositoryID: github.Ptr(int64(1)), - HeadBranch: github.Ptr("main"), - HeadSHA: github.Ptr("abc123"), - }, - }, - { - ID: github.Ptr(int64(2)), - NodeID: github.Ptr("A_2"), - Name: github.Ptr("test-results"), - SizeInBytes: github.Ptr(int64(512)), - URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/2"), - ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/2/zip"), - Expired: github.Ptr(false), - CreatedAt: &github.Timestamp{}, - UpdatedAt: &github.Timestamp{}, - ExpiresAt: &github.Timestamp{}, - WorkflowRun: &github.ArtifactWorkflowRun{ - ID: github.Ptr(int64(12345)), - RepositoryID: github.Ptr(int64(1)), - HeadRepositoryID: github.Ptr(int64(1)), - HeadBranch: github.Ptr("main"), - HeadSHA: github.Ptr("abc123"), - }, - }, - }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(artifacts) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - var response github.ArtifactList - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.NotNil(t, response.TotalCount) - assert.Greater(t, *response.TotalCount, int64(0)) - assert.NotEmpty(t, response.Artifacts) - }) - } -} - -func Test_DownloadWorkflowRunArtifact(t *testing.T) { - // Verify tool definition once - toolDef := DownloadWorkflowRunArtifact(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "download_workflow_run_artifact", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "artifact_id") - assert.ElementsMatch(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "artifact_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful artifact download URL", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "GET /repos/owner/repo/actions/artifacts/123/zip": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - // GitHub returns a 302 redirect to the download URL - w.Header().Set("Location", "https://api.github.com/repos/owner/repo/actions/artifacts/123/download") - w.WriteHeader(http.StatusFound) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "artifact_id": float64(123), - }, - expectError: false, - }, - { - name: "missing required parameter artifact_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: artifact_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Contains(t, response, "download_url") - assert.Contains(t, response, "message") - assert.Equal(t, "Artifact is available for download", response["message"]) - assert.Equal(t, float64(123), response["artifact_id"]) - }) - } -} - -func Test_DeleteWorkflowRunLogs(t *testing.T) { - // Verify tool definition once - toolDef := DeleteWorkflowRunLogs(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "delete_workflow_run_logs", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "run_id") - assert.ElementsMatch(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "run_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful logs deletion", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - DeleteReposActionsRunsLogsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Equal(t, "Workflow run logs have been deleted", response["message"]) - assert.Equal(t, float64(12345), response["run_id"]) - }) - } -} - -func Test_GetWorkflowRunUsage(t *testing.T) { - // Verify tool definition once - toolDef := GetWorkflowRunUsage(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "get_workflow_run_usage", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "run_id") - assert.ElementsMatch(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "run_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow run usage", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsRunsTimingByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - usage := &github.WorkflowRunUsage{ - Billable: &github.WorkflowRunBillMap{ - "UBUNTU": &github.WorkflowRunBill{ - TotalMS: github.Ptr(int64(120000)), - Jobs: github.Ptr(2), - JobRuns: []*github.WorkflowRunJobRun{ - { - JobID: github.Ptr(1), - DurationMS: github.Ptr(int64(60000)), - }, - { - JobID: github.Ptr(2), - DurationMS: github.Ptr(int64(60000)), - }, - }, - }, - }, - RunDurationMS: github.Ptr(int64(120000)), - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(usage) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content if no error - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - // Unmarshal and verify the result - var response github.WorkflowRunUsage - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.NotNil(t, response.RunDurationMS) - assert.NotNil(t, response.Billable) - }) - } -} - -func Test_GetJobLogs(t *testing.T) { - // Verify tool definition once - toolDef := GetJobLogs(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "get_job_logs", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "job_id") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "run_id") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "failed_only") - assert.Contains(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Properties, "return_content") - assert.ElementsMatch(t, toolDef.Tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - checkResponse func(t *testing.T, response map[string]any) - }{ - { - name: "successful single job logs with URL", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", "https://github.com/logs/job/123") - w.WriteHeader(http.StatusFound) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "job_id": float64(123), - }, - expectError: false, - checkResponse: func(t *testing.T, response map[string]any) { - assert.Equal(t, float64(123), response["job_id"]) - assert.Contains(t, response, "logs_url") - assert.Equal(t, "Job logs are available for download", response["message"]) - assert.Contains(t, response, "note") - }, - }, - { - name: "successful failed jobs logs", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jobs := &github.Jobs{ - TotalCount: github.Ptr(3), - Jobs: []*github.WorkflowJob{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("test-job-1"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test-job-2"), - Conclusion: github.Ptr("failure"), - }, - { - ID: github.Ptr(int64(3)), - Name: github.Ptr("test-job-3"), - Conclusion: github.Ptr("failure"), - }, - }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(jobs) - }), - GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:]) - w.WriteHeader(http.StatusFound) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(456), - "failed_only": true, - }, - expectError: false, - checkResponse: func(t *testing.T, response map[string]any) { - assert.Equal(t, float64(456), response["run_id"]) - assert.Equal(t, float64(3), response["total_jobs"]) - assert.Equal(t, float64(2), response["failed_jobs"]) - assert.Contains(t, response, "logs") - assert.Equal(t, "Retrieved logs for 2 failed jobs", response["message"]) - - logs, ok := response["logs"].([]interface{}) - assert.True(t, ok) - assert.Len(t, logs, 2) - }, - }, - { - name: "no failed jobs found", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jobs := &github.Jobs{ - TotalCount: github.Ptr(2), - Jobs: []*github.WorkflowJob{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("test-job-1"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test-job-2"), - Conclusion: github.Ptr("success"), - }, - }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(jobs) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(456), - "failed_only": true, - }, - expectError: false, - checkResponse: func(t *testing.T, response map[string]any) { - assert.Equal(t, "No failed jobs found in this workflow run", response["message"]) - assert.Equal(t, float64(456), response["run_id"]) - assert.Equal(t, float64(2), response["total_jobs"]) - assert.Equal(t, float64(0), response["failed_jobs"]) - }, - }, - { - name: "missing job_id when not using failed_only", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "job_id is required when failed_only is false", - }, - { - name: "missing run_id when using failed_only", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "failed_only": true, - }, - expectError: true, - expectedErrMsg: "run_id is required when failed_only is true", - }, - { - name: "missing required parameter owner", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "repo": "repo", - "job_id": float64(123), - }, - expectError: true, - expectedErrMsg: "missing required parameter: owner", - }, - { - name: "missing required parameter repo", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "job_id": float64(123), - }, - expectError: true, - expectedErrMsg: "missing required parameter: repo", - }, - { - name: "API error when getting single job logs", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _ = json.NewEncoder(w).Encode(map[string]string{ - "message": "Not Found", - }) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "job_id": float64(999), - }, - expectError: true, - }, - { - name: "API error when listing workflow jobs for failed_only", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _ = json.NewEncoder(w).Encode(map[string]string{ - "message": "Not Found", - }) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(999), - "failed_only": true, - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - ContentWindowSize: 5000, - } - handler := toolDef.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - // Parse the result and get the text content - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - if tc.expectError { - // For API errors, just verify we got an error - assert.True(t, result.IsError) - return - } - - // Unmarshal and verify the result - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - - if tc.checkResponse != nil { - tc.checkResponse(t, response) - } - }) - } -} - -func Test_GetJobLogs_WithContentReturn(t *testing.T) { - // Test the return_content functionality with a mock HTTP server - logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully" - - // Create a test server to serve log content - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(logContent)) - })) - defer testServer.Close() - - mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", testServer.URL) - w.WriteHeader(http.StatusFound) - }), - }) - - client := github.NewClient(mockedClient) - toolDef := GetJobLogs(translations.NullTranslationHelper) - deps := BaseDeps{ - Client: client, - ContentWindowSize: 5000, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(map[string]any{ - "owner": "owner", - "repo": "repo", - "job_id": float64(123), - "return_content": true, - }) - - 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, float64(123), response["job_id"]) - assert.Equal(t, logContent, response["logs_content"]) - assert.Equal(t, "Job logs content retrieved successfully", response["message"]) - assert.NotContains(t, response, "logs_url") // Should not have URL when returning content -} - -func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) { - // Test the return_content functionality with a mock HTTP server - logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully" - expectedLogContent := "2023-01-01T10:00:02.000Z Job completed successfully" - - // Create a test server to serve log content - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(logContent)) - })) - defer testServer.Close() - - mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", testServer.URL) - w.WriteHeader(http.StatusFound) - }), - }) - - client := github.NewClient(mockedClient) - toolDef := GetJobLogs(translations.NullTranslationHelper) - deps := BaseDeps{ - Client: client, - ContentWindowSize: 5000, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(map[string]any{ - "owner": "owner", - "repo": "repo", - "job_id": float64(123), - "return_content": true, - "tail_lines": float64(1), // Requesting last 1 line - }) - - 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, float64(123), response["job_id"]) - assert.Equal(t, float64(3), response["original_length"]) - assert.Equal(t, expectedLogContent, response["logs_content"]) - assert.Equal(t, "Job logs content retrieved successfully", response["message"]) - assert.NotContains(t, response, "logs_url") // Should not have URL when returning content -} - -func Test_GetJobLogs_WithContentReturnAndLargeTailLines(t *testing.T) { - logContent := "Line 1\nLine 2\nLine 3" - expectedLogContent := "Line 1\nLine 2\nLine 3" - - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(logContent)) - })) - defer testServer.Close() - - mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", testServer.URL) - w.WriteHeader(http.StatusFound) - }), - }) - - client := github.NewClient(mockedClient) - toolDef := GetJobLogs(translations.NullTranslationHelper) - deps := BaseDeps{ - Client: client, - ContentWindowSize: 5000, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(map[string]any{ - "owner": "owner", - "repo": "repo", - "job_id": float64(123), - "return_content": true, - "tail_lines": float64(100), - }) - - 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, float64(123), response["job_id"]) - assert.Equal(t, float64(3), response["original_length"]) - assert.Equal(t, expectedLogContent, response["logs_content"]) - assert.Equal(t, "Job logs content retrieved successfully", response["message"]) - assert.NotContains(t, response, "logs_url") -} - -func Test_MemoryUsage_SlidingWindow_vs_NoWindow(t *testing.T) { - if testing.Short() { - t.Skip("Skipping memory profiling test in short mode") - } - - const logLines = 100000 - const bufferSize = 5000 - largeLogContent := strings.Repeat("log line with some content\n", logLines-1) + "final log line" - - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(largeLogContent)) - })) - defer testServer.Close() - - os.Setenv("GITHUB_MCP_PROFILING_ENABLED", "true") - defer os.Unsetenv("GITHUB_MCP_PROFILING_ENABLED") - - profiler.InitFromEnv(nil) - ctx := context.Background() - - debug.SetGCPercent(-1) - defer debug.SetGCPercent(100) - - for i := 0; i < 3; i++ { - runtime.GC() - } - - var baselineStats runtime.MemStats - runtime.ReadMemStats(&baselineStats) - - profile1, err1 := profiler.ProfileFuncWithMetrics(ctx, "sliding_window", func() (int, int64, error) { - resp1, err := http.Get(testServer.URL) - if err != nil { - return 0, 0, err - } - defer resp1.Body.Close() //nolint:bodyclose - content, totalLines, _, err := buffer.ProcessResponseAsRingBufferToEnd(resp1, bufferSize) //nolint:bodyclose - return totalLines, int64(len(content)), err - }) - require.NoError(t, err1) - - for i := 0; i < 3; i++ { - runtime.GC() - } - - profile2, err2 := profiler.ProfileFuncWithMetrics(ctx, "no_window", func() (int, int64, error) { - resp2, err := http.Get(testServer.URL) - if err != nil { - return 0, 0, err - } - defer resp2.Body.Close() //nolint:bodyclose - - allContent, err := io.ReadAll(resp2.Body) - if err != nil { - return 0, 0, err - } - - allLines := strings.Split(string(allContent), "\n") - var nonEmptyLines []string - for _, line := range allLines { - if line != "" { - nonEmptyLines = append(nonEmptyLines, line) - } - } - totalLines := len(nonEmptyLines) - - var resultLines []string - if totalLines > bufferSize { - resultLines = nonEmptyLines[totalLines-bufferSize:] - } else { - resultLines = nonEmptyLines - } - - result := strings.Join(resultLines, "\n") - return totalLines, int64(len(result)), nil - }) - require.NoError(t, err2) - - assert.Greater(t, profile2.MemoryDelta, profile1.MemoryDelta, - "Sliding window should use less memory than reading all into memory") - - assert.Equal(t, profile1.LinesCount, profile2.LinesCount, - "Both approaches should count the same number of input lines") - assert.InDelta(t, profile1.BytesCount, profile2.BytesCount, 100, - "Both approaches should produce similar output sizes (within 100 bytes)") - - memoryReduction := float64(profile2.MemoryDelta-profile1.MemoryDelta) / float64(profile2.MemoryDelta) * 100 - t.Logf("Memory reduction: %.1f%% (%.2f MB vs %.2f MB)", - memoryReduction, - float64(profile2.MemoryDelta)/1024/1024, - float64(profile1.MemoryDelta)/1024/1024) - - t.Logf("Baseline: %d bytes", baselineStats.Alloc) - t.Logf("Sliding window: %s", profile1.String()) - t.Logf("No window: %s", profile2.String()) -} - -func Test_ListWorkflowRuns(t *testing.T) { - // Verify tool definition once - toolDef := ListWorkflowRuns(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "list_workflow_runs", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "workflow_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "workflow_id"}) -} - -func Test_GetWorkflowRun(t *testing.T) { - // Verify tool definition once - toolDef := GetWorkflowRun(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "get_workflow_run", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "run_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) -} - -func Test_GetWorkflowRunLogs(t *testing.T) { - // Verify tool definition once - toolDef := GetWorkflowRunLogs(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "get_workflow_run_logs", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "run_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) -} - -func Test_ListWorkflowJobs(t *testing.T) { - // Verify tool definition once - toolDef := ListWorkflowJobs(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "list_workflow_jobs", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "run_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) -} - -func Test_RerunWorkflowRun(t *testing.T) { - // Verify tool definition once - toolDef := RerunWorkflowRun(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "rerun_workflow_run", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "run_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) -} - -func Test_RerunFailedJobs(t *testing.T) { - // Verify tool definition once - toolDef := RerunFailedJobs(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) - - assert.Equal(t, "rerun_failed_jobs", toolDef.Tool.Name) - assert.NotEmpty(t, toolDef.Tool.Description) - inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, inputSchema.Properties, "owner") - assert.Contains(t, inputSchema.Properties, "repo") - assert.Contains(t, inputSchema.Properties, "run_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"owner", "repo", "run_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful rerun of failed jobs", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposActionsRunsRerunFailedJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Equal(t, "Failed jobs have been queued for re-run", response["message"]) - assert.Equal(t, float64(12345), response["run_id"]) - }) - } -} - -func Test_RerunWorkflowRun_Behavioral(t *testing.T) { - toolDef := RerunWorkflowRun(translations.NullTranslationHelper) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful rerun of workflow run", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposActionsRunsRerunByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Equal(t, "Workflow run has been queued for re-run", response["message"]) - assert.Equal(t, float64(12345), response["run_id"]) - }) - } -} - -func Test_ListWorkflowRuns_Behavioral(t *testing.T) { - toolDef := ListWorkflowRuns(translations.NullTranslationHelper) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful workflow runs listing", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - runs := &github.WorkflowRuns{ - TotalCount: github.Ptr(2), - WorkflowRuns: []*github.WorkflowRun{ - { - ID: github.Ptr(int64(123)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(456)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("failure"), - }, - }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(runs) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "workflow_id": "ci.yml", - }, - expectError: false, - }, - { - name: "missing required parameter workflow_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: workflow_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - var response github.WorkflowRuns - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.NotNil(t, response.TotalCount) - assert.Greater(t, *response.TotalCount, 0) - }) - } -} - -func Test_GetWorkflowRun_Behavioral(t *testing.T) { - toolDef := GetWorkflowRun(translations.NullTranslationHelper) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful get workflow run", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsRunsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - run := &github.WorkflowRun{ - ID: github.Ptr(int64(12345)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(run) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - var response github.WorkflowRun - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.NotNil(t, response.ID) - assert.Equal(t, int64(12345), *response.ID) - }) - } -} - -func Test_GetWorkflowRunLogs_Behavioral(t *testing.T) { - toolDef := GetWorkflowRunLogs(translations.NullTranslationHelper) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful get workflow run logs", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsRunsLogsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", "https://github.com/logs/run/12345") - w.WriteHeader(http.StatusFound) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Contains(t, response, "logs_url") - assert.Equal(t, "Workflow run logs are available for download", response["message"]) - }) - } -} - -func Test_ListWorkflowJobs_Behavioral(t *testing.T) { - toolDef := ListWorkflowJobs(translations.NullTranslationHelper) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful list workflow jobs", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jobs := &github.Jobs{ - TotalCount: github.Ptr(2), - Jobs: []*github.WorkflowJob{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("build"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("failure"), - }, - }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(jobs) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), - }, - expectError: false, - }, - { - name: "missing required parameter run_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: run_id", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := toolDef.Handler(deps) - - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.Equal(t, tc.expectError, result.IsError) - - textContent := getTextResult(t, result) - - if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) - return - } - - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Contains(t, response, "jobs") - }) - } -} - // Tests for consolidated actions tools func Test_ActionsList(t *testing.T) { @@ -1892,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, } @@ -1942,7 +136,7 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -1991,7 +185,7 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -2047,7 +241,7 @@ func Test_ActionsGet_GetWorkflow(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -2090,7 +284,7 @@ func Test_ActionsGet_GetWorkflowRun(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -2183,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, } @@ -2224,7 +449,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -2255,7 +480,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -2279,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, } @@ -2331,7 +556,7 @@ func Test_ActionsGetJobLogs_SingleJob(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, ContentWindowSize: 5000, @@ -2393,7 +618,7 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, ContentWindowSize: 5000, @@ -2443,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_quality.go b/pkg/github/code_quality.go new file mode 100644 index 0000000000..41c791182b --- /dev/null +++ b/pkg/github/code_quality.go @@ -0,0 +1,99 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + 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" +) + +func GetCodeQualityFinding(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataCodeQuality, + mcp.Tool{ + Name: "get_code_quality_finding", + Description: t("TOOL_GET_CODE_QUALITY_FINDING_DESCRIPTION", "Get details of a specific code quality finding in a GitHub repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_CODE_QUALITY_FINDING_USER_TITLE", "Get code quality finding"), + 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.", + }, + "findingNumber": { + Type: "number", + Description: "The number of the finding.", + }, + }, + Required: []string{"owner", "repo", "findingNumber"}, + }, + }, + []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 + } + findingNumber, err := RequiredInt(args, "findingNumber") + 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 + } + + apiURL := fmt.Sprintf("repos/%s/%s/code-quality/findings/%d", owner, repo, findingNumber) + req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + } + + finding := make(map[string]any) + + resp, err := client.Do(req, &finding) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get finding", 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 get finding", resp, body), nil, nil + } + + r, err := json.Marshal(finding) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal finding", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) +} diff --git a/pkg/github/code_quality_test.go b/pkg/github/code_quality_test.go new file mode 100644 index 0000000000..3971e5a0d6 --- /dev/null +++ b/pkg/github/code_quality_test.go @@ -0,0 +1,155 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" +) + +func Test_GetCodeQualityFinding(t *testing.T) { + // Verify tool definition once + toolDef := GetCodeQualityFinding(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "get_code_quality_finding", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + + // InputSchema is of type any, need to cast to *jsonschema.Schema + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "findingNumber") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "findingNumber"}) + + type codeQualityRule struct { + ID *string `json:"id,omitempty"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Help *string `json:"help,omitempty"` + Severity *string `json:"severity,omitempty"` + Category *string `json:"category,omitempty"` + } + + type codeQualityLocation struct { + Path *string `json:"path,omitempty"` + StartLine *int `json:"start_line,omitempty"` + StartColumn *int `json:"start_column,omitempty"` + EndLine *int `json:"end_line,omitempty"` + EndColumn *int `json:"end_column,omitempty"` + } + + type codeQualityMessage struct { + Text string `json:"text"` + Markdown string `json:"markdown"` + } + + type codeQualityFinding struct { + Number *int `json:"number,omitempty"` + State *string `json:"state,omitempty"` + URL *string `json:"url,omitempty"` + Rule *codeQualityRule `json:"rule,omitempty"` + Location *codeQualityLocation `json:"location,omitempty"` + Message *codeQualityMessage `json:"message,omitempty"` + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + } + + // Setup mock finding for success case + mockFinding := &codeQualityFinding{ + Number: github.Ptr(42), + State: github.Ptr("open"), + Rule: &codeQualityRule{ + ID: github.Ptr("test-rule"), + Description: github.Ptr("Test Rule Description"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedFinding *codeQualityFinding + expectedErrMsg string + }{ + { + name: "successful finding fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber: mockResponse(t, http.StatusOK, mockFinding), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "findingNumber": float64(42), + }, + expectError: false, + expectedFinding: mockFinding, + }, + { + name: "finding fetch fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "findingNumber": float64(9999), + }, + expectError: true, + expectedErrMsg: "failed to get finding", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler with new signature + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + // Verify results + 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) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedFinding codeQualityFinding + err = json.Unmarshal([]byte(textContent.Text), &returnedFinding) + assert.NoError(t, err) + assert.Equal(t, *tc.expectedFinding.Number, *returnedFinding.Number) + assert.Equal(t, *tc.expectedFinding.State, *returnedFinding.State) + assert.Equal(t, *tc.expectedFinding.Rule.ID, *returnedFinding.Rule.ID) + + }) + } +} diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index ccc00661a5..fb8b7a79c8 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -7,11 +7,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/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -88,12 +89,52 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server return utils.NewToolResultErrorFromErr("failed to marshal alert", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Code scanning alerts are access-restricted regardless of repo + // visibility and embed attacker-influenceable code snippets, so the + // label is always private-untrusted. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) + return result, nil, nil }, ) } 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 +144,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 +173,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", @@ -191,7 +214,12 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv return utils.NewToolResultErrorFromErr("failed to marshal alerts", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Code scanning alerts are access-restricted regardless of repo + // visibility and embed attacker-influenceable code snippets, so the + // label is always private-untrusted. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) + return result, nil, nil }, ) } diff --git a/pkg/github/code_scanning_test.go b/pkg/github/code_scanning_test.go index 59972fe52d..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/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -41,7 +41,7 @@ func Test_GetCodeScanningAlert(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedAlert *github.Alert expectedErrMsg string @@ -51,7 +51,7 @@ func Test_GetCodeScanningAlert(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber: mockResponse(t, http.StatusOK, mockAlert), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "alertNumber": float64(42), @@ -67,7 +67,7 @@ func Test_GetCodeScanningAlert(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "alertNumber": float64(9999), @@ -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 @@ -158,7 +160,7 @@ func Test_ListCodeScanningAlerts(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedAlerts []*github.Alert expectedErrMsg string @@ -171,11 +173,13 @@ func Test_ListCodeScanningAlerts(t *testing.T) { "state": "open", "severity": "high", "tool_name": "codeql", + "page": "1", + "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, mockAlerts), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "ref": "main", @@ -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{ @@ -194,7 +217,7 @@ func Test_ListCodeScanningAlerts(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -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 29fa2925d4..b4c7098c56 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" @@ -15,6 +16,9 @@ import ( "github.com/shurcooL/githubv4" ) +// GetMeUIResourceURI is the URI for the get_me tool's MCP App UI resource. +const GetMeUIResourceURI = "ui://github-mcp-server/get-me" + // UserDetails contains additional fields about a GitHub user not already // present in MinimalUser. Used by get_me context tool but omitted from search_users. type UserDetails struct { @@ -51,6 +55,12 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { // Use json.RawMessage to ensure "properties" is included even when empty. // OpenAI strict mode requires the properties field to be present. InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), + Meta: mcp.Meta{ + "ui": map[string]any{ + "resourceUri": GetMeUIResourceURI, + "visibility": []string{"model", "app"}, + }, + }, }, nil, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { @@ -95,7 +105,9 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { }, } - return MarshalledTextResult(minimalUser), nil, nil + result := MarshalledTextResult(minimalUser) + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGetMe()) + return result, nil, nil }, ) } @@ -179,7 +191,7 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { } `graphql:"organizations(first: 100)"` } `graphql:"user(login: $login)"` } - vars := map[string]interface{}{ + vars := map[string]any{ "login": githubv4.String(username), } if err := gqlClient.Query(ctx, &q, vars); err != nil { @@ -204,7 +216,12 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { organizations = append(organizations, orgTeams) } - return MarshalledTextResult(organizations), nil, nil + result := MarshalledTextResult(organizations) + // Team membership is maintained by GitHub and cannot be forged by + // outside contributors (trusted). Org team rosters are visible only + // to org members, so confidentiality is private. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelTeam()) + return result, nil, nil }, ) } @@ -262,7 +279,7 @@ func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { } `graphql:"team(slug: $teamSlug)"` } `graphql:"organization(login: $org)"` } - vars := map[string]interface{}{ + vars := map[string]any{ "org": githubv4.String(org), "teamSlug": githubv4.String(teamSlug), } @@ -275,7 +292,12 @@ func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { members = append(members, string(member.Login)) } - return MarshalledTextResult(members), nil, nil + result := MarshalledTextResult(members) + // Team membership is maintained by GitHub and cannot be forged by + // outside contributors (trusted). A team's member roster is visible + // only to org members, so confidentiality is private. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelTeam()) + return result, nil, nil }, ) } diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 3f4261e719..082b467135 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/v79/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,72 @@ 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"]) + // get_me returns the caller's private repo/gist counts, which are not + // part of the public profile, so confidentiality is private. + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) +} + func Test_GetTeams(t *testing.T) { t.Parallel() @@ -215,7 +282,7 @@ func Test_GetTeams(t *testing.T) { // to ensure each test gets a fresh client gqlClientForTestuser := func() *githubv4.Client { queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}" - vars := map[string]interface{}{ + vars := map[string]any{ "login": "testuser", } matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockTeamsResponse) @@ -225,7 +292,7 @@ func Test_GetTeams(t *testing.T) { gqlClientForSpecificuser := func() *githubv4.Client { queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}" - vars := map[string]interface{}{ + vars := map[string]any{ "login": "specificuser", } matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockTeamsResponse) @@ -235,7 +302,7 @@ func Test_GetTeams(t *testing.T) { gqlClientNoTeams := func() *githubv4.Client { queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}" - vars := map[string]interface{}{ + vars := map[string]any{ "login": "testuser", } matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockNoTeamsResponse) @@ -268,7 +335,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 +360,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 +371,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 +381,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 +393,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{}, @@ -419,7 +488,7 @@ func Test_GetTeamMembers(t *testing.T) { // Create GQL clients for different test scenarios gqlClientWithMembers := func() *githubv4.Client { queryStr := "query($org:String!$teamSlug:String!){organization(login: $org){team(slug: $teamSlug){members(first: 100){nodes{login}}}}}" - vars := map[string]interface{}{ + vars := map[string]any{ "org": "testorg", "teamSlug": "testteam", } @@ -430,7 +499,7 @@ func Test_GetTeamMembers(t *testing.T) { gqlClientNoMembers := func() *githubv4.Client { queryStr := "query($org:String!$teamSlug:String!){organization(login: $org){team(slug: $teamSlug){members(first: 100){nodes{login}}}}}" - vars := map[string]interface{}{ + vars := map[string]any{ "org": "testorg", "teamSlug": "emptyteam", } @@ -469,7 +538,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 new file mode 100644 index 0000000000..017bb98bc9 --- /dev/null +++ b/pkg/github/copilot.go @@ -0,0 +1,607 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "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/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/go-viper/mapstructure/v2" + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +// mvpDescription is an MVP idea for generating tool descriptions from structured data in a shared format. +// It is not intended for widespread usage and is not a complete implementation. +type mvpDescription struct { + summary string + outcomes []string + referenceLinks []string +} + +func (d *mvpDescription) String() string { + var sb strings.Builder + sb.WriteString(d.summary) + if len(d.outcomes) > 0 { + sb.WriteString("\n\n") + sb.WriteString("This tool can help with the following outcomes:\n") + for _, outcome := range d.outcomes { + sb.WriteString(fmt.Sprintf("- %s\n", outcome)) + } + } + + if len(d.referenceLinks) > 0 { + sb.WriteString("\n\n") + sb.WriteString("More information can be found at:\n") + for _, link := range d.referenceLinks { + sb.WriteString(fmt.Sprintf("- %s\n", link)) + } + } + + return sb.String() +} + +// linkedPullRequest represents a PR linked to an issue by Copilot. +type linkedPullRequest struct { + Number int + URL string + Title string + State string + CreatedAt time.Time +} + +// pollConfigKey is a context key for polling configuration. +type pollConfigKey struct{} + +// PollConfig configures the PR polling behavior. +type PollConfig struct { + MaxAttempts int + Delay time.Duration +} + +// ContextWithPollConfig returns a context with polling configuration. +// Use this in tests to reduce or disable polling. +func ContextWithPollConfig(ctx context.Context, config PollConfig) context.Context { + return context.WithValue(ctx, pollConfigKey{}, config) +} + +// getPollConfig returns the polling configuration from context, or defaults. +func getPollConfig(ctx context.Context) PollConfig { + if config, ok := ctx.Value(pollConfigKey{}).(PollConfig); ok { + return config + } + // Default: 9 attempts with 1s delay = 8s max wait + // Based on observed latency in remote server: p50 ~5s, p90 ~7s + return PollConfig{MaxAttempts: 9, Delay: 1 * time.Second} +} + +// findLinkedCopilotPR searches for a PR created by the copilot-swe-agent bot that references the given issue. +// It queries the issue's timeline for CrossReferencedEvent items from PRs authored by copilot-swe-agent. +// The createdAfter parameter filters to only return PRs created after the specified time. +func findLinkedCopilotPR(ctx context.Context, client *githubv4.Client, owner, repo string, issueNumber int, createdAfter time.Time) (*linkedPullRequest, error) { + // Query timeline items looking for CrossReferencedEvent from PRs by copilot-swe-agent + var query struct { + Repository struct { + Issue struct { + TimelineItems struct { + Nodes []struct { + TypeName string `graphql:"__typename"` + CrossReferencedEvent struct { + Source struct { + PullRequest struct { + Number int + URL string + Title string + State string + CreatedAt githubv4.DateTime + Author struct { + Login string + } + } `graphql:"... on PullRequest"` + } + } `graphql:"... on CrossReferencedEvent"` + } + } `graphql:"timelineItems(first: 20, itemTypes: [CROSS_REFERENCED_EVENT])"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]any{ + "owner": githubv4.String(owner), + "name": githubv4.String(repo), + "number": githubv4.Int(issueNumber), //nolint:gosec // Issue numbers are always small positive integers + } + + if err := client.Query(ctx, &query, variables); err != nil { + return nil, err + } + + // Look for a PR from copilot-swe-agent created after the assignment time + for _, node := range query.Repository.Issue.TimelineItems.Nodes { + if node.TypeName != "CrossReferencedEvent" { + continue + } + pr := node.CrossReferencedEvent.Source.PullRequest + if pr.Number > 0 && pr.Author.Login == "copilot-swe-agent" { + // Only return PRs created after the assignment time + if pr.CreatedAt.Time.After(createdAfter) { + return &linkedPullRequest{ + Number: pr.Number, + URL: pr.URL, + Title: pr.Title, + State: pr.State, + CreatedAt: pr.CreatedAt.Time, + }, nil + } + } + } + + return nil, nil +} + +func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.ServerTool { + description := mvpDescription{ + summary: "Assign Copilot to a specific issue in a GitHub repository.", + outcomes: []string{ + "a Pull Request created with source code changes to resolve the issue", + }, + referenceLinks: []string{ + "https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot", + }, + } + + return NewTool( + ToolsetMetadataCopilot, + mcp.Tool{ + Name: "assign_copilot_to_issue", + Description: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String()), + Icons: octicons.Icons("copilot"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_USER_TITLE", "Assign Copilot to issue"), + ReadOnlyHint: false, + IdempotentHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "Issue number", + }, + "base_ref": { + Type: "string", + Description: "Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch", + }, + "custom_instructions": { + Type: "string", + Description: "Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description", + }, + }, + Required: []string{"owner", "repo", "issue_number"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + var params struct { + Owner string `mapstructure:"owner"` + Repo string `mapstructure:"repo"` + IssueNumber int32 `mapstructure:"issue_number"` + BaseRef string `mapstructure:"base_ref"` + CustomInstructions string `mapstructure:"custom_instructions"` + } + if err := mapstructure.WeakDecode(args, ¶ms); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Firstly, we try to find the copilot bot in the suggested actors for the repository. + // Although as I write this, we would expect copilot to be at the top of the list, in future, maybe + // it will not be on the first page of responses, thus we will keep paginating until we find it. + type botAssignee struct { + ID githubv4.ID + Login string + TypeName string `graphql:"__typename"` + } + + type suggestedActorsQuery struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot botAssignee `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]any{ + "owner": githubv4.String(params.Owner), + "name": githubv4.String(params.Repo), + "endCursor": (*githubv4.String)(nil), + } + + var copilotAssignee *botAssignee + for { + var query suggestedActorsQuery + err := client.Query(ctx, &query, variables) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get suggested actors", err), nil, nil + } + + // Iterate all the returned nodes looking for the copilot bot, which is supposed to have the + // same name on each host. We need this in order to get the ID for later assignment. + for _, node := range query.Repository.SuggestedActors.Nodes { + if node.Bot.Login == "copilot-swe-agent" { + copilotAssignee = &node.Bot + break + } + } + + if !query.Repository.SuggestedActors.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Repository.SuggestedActors.PageInfo.EndCursor) + } + + // If we didn't find the copilot bot, we can't proceed any further. + if copilotAssignee == nil { + // The e2e tests depend upon this specific message to skip the test. + return utils.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil, nil + } + + // Next, get the issue ID and repository ID + var getIssueQuery struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables = map[string]any{ + "owner": githubv4.String(params.Owner), + "name": githubv4.String(params.Repo), + "number": githubv4.Int(params.IssueNumber), + } + + if err := client.Query(ctx, &getIssueQuery, variables); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue ID", err), nil, nil + } + + // Build the assignee IDs list including copilot + actorIDs := make([]githubv4.ID, len(getIssueQuery.Repository.Issue.Assignees.Nodes)+1) + for i, node := range getIssueQuery.Repository.Issue.Assignees.Nodes { + actorIDs[i] = node.ID + } + actorIDs[len(getIssueQuery.Repository.Issue.Assignees.Nodes)] = copilotAssignee.ID + + // Prepare agent assignment input + emptyString := githubv4.String("") + agentAssignment := &AgentAssignmentInput{ + CustomAgent: &emptyString, + CustomInstructions: &emptyString, + TargetRepositoryID: getIssueQuery.Repository.ID, + } + + // Add base ref if provided + if params.BaseRef != "" { + baseRef := githubv4.String(params.BaseRef) + agentAssignment.BaseRef = &baseRef + } + + // Add custom instructions if provided + if params.CustomInstructions != "" { + customInstructions := githubv4.String(params.CustomInstructions) + agentAssignment.CustomInstructions = &customInstructions + } + + // Execute the updateIssue mutation with the GraphQL-Features header + // This header is required for the agent assignment API which is not GA yet + var updateIssueMutation struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + } + + // Add the GraphQL-Features header for the agent assignment API + // The header will be read by the HTTP transport if it's configured to do so + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issues_copilot_assignment_api_support") + + // Capture the time before assignment to filter out older PRs during polling + assignmentTime := time.Now().UTC() + + if err := client.Mutate( + ctxWithFeatures, + &updateIssueMutation, + UpdateIssueInput{ + ID: getIssueQuery.Repository.Issue.ID, + AssigneeIDs: actorIDs, + AgentAssignment: agentAssignment, + }, + nil, + ); err != nil { + return nil, nil, fmt.Errorf("failed to update issue with agent assignment: %w", err) + } + + // Poll for a linked PR created by Copilot after the assignment + pollConfig := getPollConfig(ctx) + + // Get progress token from request for sending progress notifications + progressToken := request.Params.GetProgressToken() + + // Send initial progress notification that assignment succeeded and polling is starting + if progressToken != nil && request.Session != nil && pollConfig.MaxAttempts > 0 { + _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: progressToken, + Progress: 0, + Total: float64(pollConfig.MaxAttempts), + Message: "Copilot assigned to issue, waiting for PR creation...", + }) + } + + var linkedPR *linkedPullRequest + for attempt := range pollConfig.MaxAttempts { + if attempt > 0 { + time.Sleep(pollConfig.Delay) + } + + // Send progress notification if progress token is available + if progressToken != nil && request.Session != nil { + _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: progressToken, + Progress: float64(attempt + 1), + Total: float64(pollConfig.MaxAttempts), + Message: fmt.Sprintf("Waiting for Copilot to create PR... (attempt %d/%d)", attempt+1, pollConfig.MaxAttempts), + }) + } + + pr, err := findLinkedCopilotPR(ctx, client, params.Owner, params.Repo, int(params.IssueNumber), assignmentTime) + if err != nil { + // Polling errors are non-fatal, continue to next attempt + continue + } + if pr != nil { + linkedPR = pr + break + } + } + + // Build the result + result := map[string]any{ + "message": "successfully assigned copilot to issue", + "issue_number": int(updateIssueMutation.UpdateIssue.Issue.Number), + "issue_url": string(updateIssueMutation.UpdateIssue.Issue.URL), + "owner": params.Owner, + "repo": params.Repo, + } + + // Add PR info if found during polling + if linkedPR != nil { + result["pull_request"] = map[string]any{ + "number": linkedPR.Number, + "url": linkedPR.URL, + "title": linkedPR.Title, + "state": linkedPR.State, + } + result["message"] = "successfully assigned copilot to issue - pull request created" + } else { + result["message"] = "successfully assigned copilot to issue - pull request pending" + result["note"] = "The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates." + } + + r, err := json.Marshal(result) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil + } + + return utils.NewToolResultText(string(r)), result, nil + }) +} + +type ReplaceActorsForAssignableInput struct { + AssignableID githubv4.ID `json:"assignableId"` + ActorIDs []githubv4.ID `json:"actorIds"` +} + +// AgentAssignmentInput represents the input for assigning an agent to an issue. +type AgentAssignmentInput struct { + BaseRef *githubv4.String `json:"baseRef,omitempty"` + CustomAgent *githubv4.String `json:"customAgent,omitempty"` + CustomInstructions *githubv4.String `json:"customInstructions,omitempty"` + TargetRepositoryID githubv4.ID `json:"targetRepositoryId"` +} + +// UpdateIssueInput represents the input for updating an issue with agent assignment. +type UpdateIssueInput struct { + ID githubv4.ID `json:"id"` + AssigneeIDs []githubv4.ID `json:"assigneeIds"` + AgentAssignment *AgentAssignmentInput `json:"agentAssignment,omitempty"` +} + +// RequestCopilotReview creates a tool to request a Copilot review for a pull request. +// Note that this tool will not work on GHES where this feature is unsupported. In future, we should not expose this +// tool if the configured host does not support it. +func RequestCopilotReview(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", + }, + "pullNumber": { + Type: "number", + Description: "Pull request number", + }, + }, + Required: []string{"owner", "repo", "pullNumber"}, + } + + return NewTool( + ToolsetMetadataCopilot, + mcp.Tool{ + Name: "request_copilot_review", + Description: t("TOOL_REQUEST_COPILOT_REVIEW_DESCRIPTION", "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer."), + Icons: octicons.Icons("copilot"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_REQUEST_COPILOT_REVIEW_USER_TITLE", "Request Copilot review"), + ReadOnlyHint: false, + }, + 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 + } + + pullNumber, err := RequiredInt(args, "pullNumber") + 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 + } + + _, resp, err := client.PullRequests.RequestReviewers( + ctx, + owner, + repo, + pullNumber, + github.ReviewersRequest{ + // The login name of the copilot reviewer bot + Reviewers: []string{"copilot-pull-request-reviewer[bot]"}, + }, + ) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to request copilot review", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + bodyBytes, 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 request copilot review", resp, bodyBytes), nil, nil + } + + // Return nothing on success, as there's not much value in returning the Pull Request itself + return utils.NewToolResultText(""), nil, nil + }) +} + +func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) inventory.ServerPrompt { + return inventory.NewServerPrompt( + ToolsetMetadataIssues, + mcp.Prompt{ + Name: "AssignCodingAgent", + Description: t("PROMPT_ASSIGN_CODING_AGENT_DESCRIPTION", "Assign GitHub Coding Agent to multiple tasks in a GitHub repository."), + Arguments: []*mcp.PromptArgument{ + { + Name: "repo", + Description: "The repository to assign tasks in (owner/repo).", + Required: true, + }, + }, + }, + func(_ context.Context, request *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + repo := request.Params.Arguments["repo"] + + messages := []*mcp.PromptMessage{ + { + Role: "user", + Content: &mcp.TextContent{ + Text: "You are a personal assistant for GitHub the Copilot GitHub Coding Agent. Your task is to help the user assign tasks to the Coding Agent based on their open GitHub issues. You can use `assign_copilot_to_issue` tool to assign the Coding Agent to issues that are suitable for autonomous work, and `search_issues` tool to find issues that match the user's criteria. You can also use `list_issues` to get a list of issues in the repository.", + }, + }, + { + Role: "user", + Content: &mcp.TextContent{ + Text: fmt.Sprintf("Please go and get a list of the most recent 10 issues from the %s GitHub repository", repo), + }, + }, + { + Role: "assistant", + Content: &mcp.TextContent{ + Text: fmt.Sprintf("Sure! I will get a list of the 10 most recent issues for the repo %s.", repo), + }, + }, + { + Role: "user", + Content: &mcp.TextContent{ + Text: "For each issue, please check if it is a clearly defined coding task with acceptance criteria and a low to medium complexity to identify issues that are suitable for an AI Coding Agent to work on. Then assign each of the identified issues to Copilot.", + }, + }, + { + Role: "assistant", + Content: &mcp.TextContent{ + Text: "Certainly! Let me carefully check which ones are clearly scoped issues that are good to assign to the coding agent, and I will summarize and assign them now.", + }, + }, + { + Role: "user", + Content: &mcp.TextContent{ + Text: "Great, if you are unsure if an issue is good to assign, ask me first, rather than assigning copilot. If you are certain the issue is clear and suitable you can assign it to Copilot without asking.", + }, + }, + } + return &mcp.GetPromptResult{ + Messages: messages, + }, nil + }, + ) +} diff --git a/pkg/github/copilot_test.go b/pkg/github/copilot_test.go new file mode 100644 index 0000000000..b86f26f474 --- /dev/null +++ b/pkg/github/copilot_test.go @@ -0,0 +1,963 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAssignCopilotToIssue(t *testing.T) { + t.Parallel() + + // Verify tool definition + serverTool := AssignCopilotToIssue(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "assign_copilot_to_issue", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "base_ref") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "custom_instructions") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issue_number"}) + + // Helper function to create pointer to githubv4.String + ptrGitHubv4String := func(s string) *githubv4.String { + v := githubv4.String(s) + return &v + } + + var pageOfFakeBots = func(n int) []struct{} { + // We don't _really_ need real bots here, just objects that count as entries for the page + bots := make([]struct{}, n) + for i := range n { + bots[i] = struct{}{} + } + return bots + } + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful assignment when there are no existing assignees", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ), + ), + }, + { + name: "successful assignment with string issue_number", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": "123", // Some MCP clients send numeric values as strings + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ), + ), + }, + { + name: "successful assignment when there are existing assignees", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("existing-assignee-id"), + }, + map[string]any{ + "id": githubv4.ID("existing-assignee-id-2"), + }, + }, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{ + githubv4.ID("existing-assignee-id"), + githubv4.ID("existing-assignee-id-2"), + githubv4.ID("copilot-swe-agent-id"), + }, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ), + ), + }, + { + name: "copilot bot not on first page of suggested actors", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + // First page of suggested actors + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": pageOfFakeBots(100), + "pageInfo": map[string]any{ + "hasNextPage": true, + "endCursor": githubv4.String("next-page-cursor"), + }, + }, + }, + }), + ), + // Second page of suggested actors + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": githubv4.String("next-page-cursor"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ), + ), + }, + { + name: "copilot not a suggested actor", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{}, + }, + }, + }), + ), + ), + expectToolError: true, + expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.", + }, + { + name: "successful assignment with base_ref specified", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "base_ref": "feature-branch", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: ptrGitHubv4String("feature-branch"), + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ), + ), + }, + { + name: "successful assignment with custom_instructions specified", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "custom_instructions": "Please ensure all code follows PEP 8 style guidelines and includes comprehensive docstrings", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String("Please ensure all code follows PEP 8 style guidelines and includes comprehensive docstrings"), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ), + ), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + + t.Parallel() + // Setup client with mock + client := githubv4.NewClient(tc.mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Disable polling in tests to avoid timeouts + ctx := ContextWithPollConfig(context.Background(), PollConfig{MaxAttempts: 0}) + ctx = ContextWithDeps(ctx, deps) + + // Call handler + result, err := handler(ctx, &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + if tc.expectToolError { + require.True(t, result.IsError) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + require.False(t, result.IsError, fmt.Sprintf("expected there to be no tool error, text was %s", textContent.Text)) + + // Verify the JSON response contains expected fields + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err, "response should be valid JSON") + assert.Equal(t, float64(123), response["issue_number"]) + assert.Equal(t, "https://github.com/owner/repo/issues/123", response["issue_url"]) + assert.Equal(t, "owner", response["owner"]) + assert.Equal(t, "repo", response["repo"]) + assert.Contains(t, response["message"], "successfully assigned copilot to issue") + }) + } +} + +func Test_RequestCopilotReview(t *testing.T) { + t.Parallel() + + serverTool := RequestCopilotReview(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "request_copilot_review", tool.Name) + assert.NotEmpty(t, tool.Description) + schema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "pullNumber") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber"}) + + // Setup mock PR for success case + mockPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Test PR"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("abcd1234"), + Ref: github.Ptr("feature-branch"), + }, + Base: &github.PullRequestBranch{ + Ref: github.Ptr("main"), + }, + Body: github.Ptr("This is a test PR"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful request", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: expect(t, expectations{ + path: "/repos/owner/repo/pulls/1/requested_reviewers", + requestBody: map[string]any{ + "reviewers": []any{"copilot-pull-request-reviewer[bot]"}, + }, + }).andThen( + mockResponse(t, http.StatusCreated, mockPR), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(1), + }, + expectError: false, + }, + { + name: "request fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to request copilot review", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + client := mustNewGHClient(t, tc.mockedClient) + serverTool := RequestCopilotReview(translations.NullTranslationHelper) + 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) + assert.NotNil(t, result) + assert.Len(t, result.Content, 1) + + textContent := getTextResult(t, result) + require.Equal(t, "", textContent.Text) + }) + } +} 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 b6b2eeaba7..1ac6b1b44c 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.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/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -69,7 +70,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 @@ -89,12 +90,44 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo return utils.NewToolResultErrorFromErr("failed to marshal alert", err), nil, err } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Dependabot alerts are access-restricted regardless of repo + // visibility and embed attacker-influenceable advisory text, so the + // label is always private-untrusted. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) + return result, nil, nil }, ) } 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"}, + } + WithCursorPagination(schema) + return NewTool( ToolsetMetadataDependabot, mcp.Tool{ @@ -104,31 +137,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 +158,11 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server return utils.NewToolResultError(err.Error()), nil, nil } + pagination, err := OptionalCursorPaginationParams(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 +171,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), + ListCursorOptions: github.ListCursorOptions{ + PerPage: pagination.PerPage, + After: pagination.After, + }, }) 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 @@ -175,12 +193,35 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list alerts", resp, body), nil, nil } - r, err := json.Marshal(alerts) + response := map[string]any{ + "alerts": alerts, + "pageInfo": buildPageInfo(resp), + } + + r, err := json.Marshal(response) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal alerts", err), nil, err } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Dependabot alerts are access-restricted regardless of repo + // visibility and embed attacker-influenceable advisory text, so the + // label is always private-untrusted. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) + return result, nil, nil }, ) } + +// 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 e57405a8cb..5236c6d349 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/v79/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -34,7 +34,7 @@ func Test_GetDependabotAlert(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedAlert *github.DependabotAlert expectedErrMsg string @@ -44,7 +44,7 @@ func Test_GetDependabotAlert(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposDependabotAlertsByOwnerByRepoByAlertNumber: mockResponse(t, http.StatusOK, mockAlert), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "alertNumber": float64(42), @@ -60,20 +60,36 @@ func Test_GetDependabotAlert(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "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) @@ -138,23 +154,25 @@ func Test_ListDependabotAlerts(t *testing.T) { } tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedAlerts []*github.DependabotAlert - expectedErrMsg string + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedAlerts []*github.DependabotAlert + expectedNextCursor string + expectedErrMsg string }{ { name: "successful open alerts listing", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ - "state": "open", + "state": "open", + "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "state": "open", @@ -167,11 +185,12 @@ func Test_ListDependabotAlerts(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ "severity": "high", + "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&highSeverityAlert}), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "severity": "high", @@ -182,17 +201,61 @@ 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{ + "per_page": "30", + }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, expectError: false, expectedAlerts: []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}, }, + { + name: "successful alerts listing with cursor pagination", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "after": "Y3Vyc29yOnYyOpK5", + "per_page": "100", + }).andThen( + mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "after": "Y3Vyc29yOnYyOpK5", + "perPage": float64(100), + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&criticalAlert}, + }, + { + name: "successful alerts listing surfaces next page cursor", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "per_page": "30", + }).andThen( + func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Link", `; rel="next"`) + w.WriteHeader(http.StatusOK) + b, err := json.Marshal([]*github.DependabotAlert{&criticalAlert}) + require.NoError(t, err) + _, _ = w.Write(b) + }, + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&criticalAlert}, + expectedNextCursor: "nextcursor123", + }, { name: "alerts listing fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -201,18 +264,33 @@ func Test_ListDependabotAlerts(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, 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) @@ -234,11 +312,17 @@ func Test_ListDependabotAlerts(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var returnedAlerts []*github.DependabotAlert - err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) + var returnedResult struct { + Alerts []*github.DependabotAlert `json:"alerts"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + NextCursor string `json:"nextCursor"` + } `json:"pageInfo"` + } + err = json.Unmarshal([]byte(textContent.Text), &returnedResult) assert.NoError(t, err) - assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) - for i, alert := range returnedAlerts { + assert.Len(t, returnedResult.Alerts, len(tc.expectedAlerts)) + for i, alert := range returnedResult.Alerts { assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) @@ -247,6 +331,8 @@ func Test_ListDependabotAlerts(t *testing.T) { assert.Equal(t, *tc.expectedAlerts[i].SecurityAdvisory.Severity, *alert.SecurityAdvisory.Severity) } } + assert.Equal(t, tc.expectedNextCursor, returnedResult.PageInfo.NextCursor) + assert.Equal(t, tc.expectedNextCursor != "", returnedResult.PageInfo.HasNextPage) }) } } diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index 15d807a249..1141fbce89 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -4,14 +4,21 @@ import ( "context" "errors" "fmt" + "log/slog" + "net/http" "os" + ghcontext "github.com/github/github-mcp-server/pkg/context" + "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" - gogithub "github.com/google/go-github/v79/github" + "github.com/github/github-mcp-server/pkg/utils" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) @@ -23,6 +30,14 @@ type depsContextKey struct{} // ErrDepsNotInContext is returned when ToolDependencies is not found in context. var ErrDepsNotInContext = errors.New("ToolDependencies not found in context; use ContextWithDeps to inject") +func InjectDepsMiddleware(deps ToolDependencies) mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, err error) { + return next(ContextWithDeps(ctx, deps), method, req) + } + } +} + // ContextWithDeps returns a new context with the ToolDependencies stored in it. // This is used to inject dependencies at request time rather than at registration time, // avoiding expensive closure creation during server initialization. @@ -69,19 +84,27 @@ type ToolDependencies interface { GetRawClient(ctx context.Context) (*raw.Client, error) // GetRepoAccessCache returns the lockdown mode repo access cache - GetRepoAccessCache() *lockdown.RepoAccessCache + GetRepoAccessCache(ctx context.Context) (*lockdown.RepoAccessCache, error) // GetT returns the translation helper function GetT() translations.TranslationHelperFunc // GetFlags returns feature flags - GetFlags() FeatureFlags + GetFlags(ctx context.Context) FeatureFlags // GetContentWindowSize returns the content window size for log truncation GetContentWindowSize() int // IsFeatureEnabled checks if a feature flag is enabled. IsFeatureEnabled(ctx context.Context, flagName string) bool + + // 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. @@ -101,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. @@ -116,6 +142,7 @@ func NewBaseDeps( flags FeatureFlags, contentWindowSize int, featureChecker inventory.FeatureFlagChecker, + obsv observability.Exporters, ) *BaseDeps { return &BaseDeps{ Client: client, @@ -126,6 +153,7 @@ func NewBaseDeps( Flags: flags, ContentWindowSize: contentWindowSize, featureChecker: featureChecker, + Obsv: obsv, } } @@ -145,17 +173,29 @@ func (d BaseDeps) GetRawClient(_ context.Context) (*raw.Client, error) { } // GetRepoAccessCache implements ToolDependencies. -func (d BaseDeps) GetRepoAccessCache() *lockdown.RepoAccessCache { return d.RepoAccessCache } +func (d BaseDeps) GetRepoAccessCache(_ context.Context) (*lockdown.RepoAccessCache, error) { + return d.RepoAccessCache, nil +} // GetT implements ToolDependencies. func (d BaseDeps) GetT() translations.TranslationHelperFunc { return d.T } // GetFlags implements ToolDependencies. -func (d BaseDeps) GetFlags() FeatureFlags { return d.Flags } +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. @@ -213,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) }) @@ -221,3 +261,183 @@ func NewToolFromHandler( st.AcceptedScopes = scopes.ExpandScopes(requiredScopes...) return st } + +type RequestDeps struct { + // Static dependencies + apiHosts utils.APIHostResolver + version string + lockdownMode bool + RepoAccessOpts []lockdown.RepoAccessOption + T translations.TranslationHelperFunc + ContentWindowSize int + + // 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. +func NewRequestDeps( + apiHosts utils.APIHostResolver, + version string, + lockdownMode bool, + repoAccessOpts []lockdown.RepoAccessOption, + t translations.TranslationHelperFunc, + contentWindowSize int, + featureChecker inventory.FeatureFlagChecker, + obsv observability.Exporters, +) *RequestDeps { + return &RequestDeps{ + apiHosts: apiHosts, + version: version, + lockdownMode: lockdownMode, + RepoAccessOpts: repoAccessOpts, + T: t, + ContentWindowSize: contentWindowSize, + featureChecker: featureChecker, + obsv: obsv, + } +} + +// GetClient implements ToolDependencies. +func (d *RequestDeps) GetClient(ctx context.Context) (*gogithub.Client, error) { + // extract the token from the context + tokenInfo, ok := ghcontext.GetTokenInfo(ctx) + if !ok { + return nil, fmt.Errorf("no token info in context") + } + token := tokenInfo.Token + + baseRestURL, err := d.apiHosts.BaseRESTURL(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get base REST URL: %w", err) + } + uploadURL, err := d.apiHosts.UploadURL(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get upload URL: %w", err) + } + + // Construct REST client + 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 +} + +// GetGQLClient implements ToolDependencies. +func (d *RequestDeps) GetGQLClient(ctx context.Context) (*githubv4.Client, error) { + // extract the token from the context + tokenInfo, ok := ghcontext.GetTokenInfo(ctx) + if !ok { + return nil, fmt.Errorf("no token info in context") + } + token := tokenInfo.Token + + // Construct GraphQL client + // We use NewEnterpriseClient unconditionally since we already parsed the API host + // Wrap transport with GraphQLFeaturesTransport to inject feature flags from context, + // matching the transport chain used by the remote server. + gqlHTTPClient := &http.Client{ + Transport: &transport.BearerAuthTransport{ + Transport: &transport.GraphQLFeaturesTransport{ + Transport: http.DefaultTransport, + }, + Token: token, + }, + } + + graphqlURL, err := d.apiHosts.GraphqlURL(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GraphQL URL: %w", err) + } + + gqlClient := githubv4.NewEnterpriseClient(graphqlURL.String(), gqlHTTPClient) + return gqlClient, nil +} + +// GetRawClient implements ToolDependencies. +func (d *RequestDeps) GetRawClient(ctx context.Context) (*raw.Client, error) { + client, err := d.GetClient(ctx) + if err != nil { + return nil, err + } + + rawURL, err := d.apiHosts.RawURL(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Raw URL: %w", err) + } + + rawClient, err := raw.NewClient(client, rawURL) + if err != nil { + return nil, fmt.Errorf("failed to create raw client: %w", err) + } + + return rawClient, nil +} + +// GetRepoAccessCache implements ToolDependencies. +func (d *RequestDeps) GetRepoAccessCache(ctx context.Context) (*lockdown.RepoAccessCache, error) { + if !d.lockdownMode { + return nil, nil + } + + gqlClient, err := d.GetGQLClient(ctx) + if err != nil { + return nil, err + } + + restClient, err := d.GetClient(ctx) + if err != nil { + return nil, err + } + + // Create repo access cache + instance := lockdown.NewRepoAccessCache(gqlClient, restClient, d.RepoAccessOpts...) + return instance, nil +} + +// GetT implements ToolDependencies. +func (d *RequestDeps) GetT() translations.TranslationHelperFunc { return d.T } + +// GetFlags implements ToolDependencies. +func (d *RequestDeps) GetFlags(ctx context.Context) FeatureFlags { + return FeatureFlags{ + LockdownMode: d.lockdownMode && ghcontext.IsLockdownMode(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 == "" { + return false + } + + enabled, err := d.featureChecker(ctx, flagName) + if err != nil { + // Log error but don't fail the tool - treat as disabled + fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", flagName, err) + return false + } + + return enabled +} 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 c036708187..68ed014b2b 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -4,13 +4,15 @@ import ( "context" "encoding/json" "fmt" + "strings" + "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/go-viper/mapstructure/v2" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -214,7 +216,7 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool categoryID = &id } - vars := map[string]interface{}{ + vars := map[string]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "first": githubv4.Int(*paginationParams.First), @@ -256,9 +258,9 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool } // Create response with pagination info - response := map[string]interface{}{ + response := map[string]any{ "discussions": discussions, - "pageInfo": map[string]interface{}{ + "pageInfo": map[string]any{ "hasNextPage": pageInfo.HasNextPage, "hasPreviousPage": pageInfo.HasPreviousPage, "startCursor": string(pageInfo.StartCursor), @@ -271,7 +273,11 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool if err != nil { return nil, nil, fmt.Errorf("failed to marshal discussions: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + result := utils.NewToolResultText(string(out)) + // Discussion content is user-authored (untrusted); confidentiality + // follows repo visibility. + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoUserContent) + return result, nil, nil }, ) } @@ -313,7 +319,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { Repo string DiscussionNumber int32 } - if err := mapstructure.Decode(args, ¶ms); err != nil { + if err := mapstructure.WeakDecode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetGQLClient(ctx) @@ -338,7 +344,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { } `graphql:"discussion(number: $discussionNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` } - vars := map[string]interface{}{ + vars := map[string]any{ "owner": githubv4.String(params.Owner), "repo": githubv4.String(params.Repo), "discussionNumber": githubv4.Int(params.DiscussionNumber), @@ -352,7 +358,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { // The go-github library's Discussion type lacks isAnswered and answerChosenAt fields, // so we use map[string]interface{} for the response (consistent with other functions // like ListDiscussions and GetDiscussionComments). - response := map[string]interface{}{ + response := map[string]any{ "number": int(d.Number), "title": string(d.Title), "body": string(d.Body), @@ -360,7 +366,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { "closed": bool(d.Closed), "isAnswered": bool(d.IsAnswered), "createdAt": d.CreatedAt.Time, - "category": map[string]interface{}{ + "category": map[string]any{ "name": string(d.Category.Name), }, } @@ -375,7 +381,11 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + result := utils.NewToolResultText(string(out)) + // Discussion content is user-authored (untrusted); confidentiality + // follows repo visibility. + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent) + return result, nil, nil }, ) } @@ -405,6 +415,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"}, }), @@ -417,7 +431,12 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve Repo string DiscussionNumber int32 } - if err := mapstructure.Decode(args, ¶ms); err != nil { + if err := mapstructure.WeakDecode(args, ¶ms); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + includeReplies, err := OptionalParam[bool](args, "includeReplies") + if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } @@ -447,25 +466,7 @@ 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]interface{}{ + vars := map[string]any{ "owner": githubv4.String(params.Owner), "repo": githubv4.String(params.Repo), "discussionNumber": githubv4.Int(params.DiscussionNumber), @@ -476,25 +477,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]interface{}{ + response := map[string]any{ "comments": comments, - "pageInfo": map[string]interface{}{ - "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), + "pageInfo": map[string]any{ + "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) @@ -502,11 +589,418 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve return nil, nil, fmt.Errorf("failed to marshal comments: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + result := utils.NewToolResultText(string(out)) + // Discussion comments are user-authored (untrusted); confidentiality + // follows repo visibility. + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent) + return result, nil, nil }, ) } +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, @@ -570,7 +1064,7 @@ func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.Se } `graphql:"discussionCategories(first: $first)"` } `graphql:"repository(owner: $owner, name: $repo)"` } - vars := map[string]interface{}{ + vars := map[string]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "first": githubv4.Int(25), @@ -588,9 +1082,9 @@ func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.Se } // Create response with pagination info - response := map[string]interface{}{ + response := map[string]any{ "categories": categories, - "pageInfo": map[string]interface{}{ + "pageInfo": map[string]any{ "hasNextPage": q.Repository.DiscussionCategories.PageInfo.HasNextPage, "hasPreviousPage": q.Repository.DiscussionCategories.PageInfo.HasPreviousPage, "startCursor": string(q.Repository.DiscussionCategories.PageInfo.StartCursor), @@ -603,7 +1097,11 @@ func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.Se if err != nil { return nil, nil, fmt.Errorf("failed to marshal discussion categories: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + result := utils.NewToolResultText(string(out)) + // Discussion categories are repo-defined structural metadata + // (trusted); confidentiality follows repo visibility. + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) + return result, nil, nil }, ) } diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 0ec9982805..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/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -228,21 +228,21 @@ func Test_ListDiscussions(t *testing.T) { assert.ElementsMatch(t, schema.Required, []string{"owner"}) // Variables matching what GraphQL receives after JSON marshaling/unmarshaling - varsListAll := map[string]interface{}{ + varsListAll := map[string]any{ "owner": "owner", "repo": "repo", "first": float64(30), "after": (*string)(nil), } - varsRepoNotFound := map[string]interface{}{ + varsRepoNotFound := map[string]any{ "owner": "owner", "repo": "nonexistent-repo", "first": float64(30), "after": (*string)(nil), } - varsDiscussionsFiltered := map[string]interface{}{ + varsDiscussionsFiltered := map[string]any{ "owner": "owner", "repo": "repo", "categoryId": "DIC_kwDOABC123", @@ -250,7 +250,7 @@ func Test_ListDiscussions(t *testing.T) { "after": (*string)(nil), } - varsOrderByCreatedAsc := map[string]interface{}{ + varsOrderByCreatedAsc := map[string]any{ "owner": "owner", "repo": "repo", "orderByField": "CREATED_AT", @@ -259,7 +259,7 @@ func Test_ListDiscussions(t *testing.T) { "after": (*string)(nil), } - varsOrderByUpdatedDesc := map[string]interface{}{ + varsOrderByUpdatedDesc := map[string]any{ "owner": "owner", "repo": "repo", "orderByField": "UPDATED_AT", @@ -268,7 +268,7 @@ func Test_ListDiscussions(t *testing.T) { "after": (*string)(nil), } - varsCategoryWithOrder := map[string]interface{}{ + varsCategoryWithOrder := map[string]any{ "owner": "owner", "repo": "repo", "categoryId": "DIC_kwDOABC123", @@ -278,7 +278,7 @@ func Test_ListDiscussions(t *testing.T) { "after": (*string)(nil), } - varsOrgLevel := map[string]interface{}{ + varsOrgLevel := map[string]any{ "owner": "owner", "repo": ".github", // This is what gets set when repo is not provided "first": float64(30), @@ -287,7 +287,7 @@ func Test_ListDiscussions(t *testing.T) { tests := []struct { name string - reqParams map[string]interface{} + reqParams map[string]any expectError bool errContains string expectedCount int @@ -295,7 +295,7 @@ func Test_ListDiscussions(t *testing.T) { }{ { name: "list all discussions without category filter", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -304,7 +304,7 @@ func Test_ListDiscussions(t *testing.T) { }, { name: "filter by category ID", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", "repo": "repo", "category": "DIC_kwDOABC123", @@ -314,7 +314,7 @@ func Test_ListDiscussions(t *testing.T) { }, { name: "order by created at ascending", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", "repo": "repo", "orderBy": "CREATED_AT", @@ -332,7 +332,7 @@ func Test_ListDiscussions(t *testing.T) { }, { name: "order by updated at descending", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", "repo": "repo", "orderBy": "UPDATED_AT", @@ -350,7 +350,7 @@ func Test_ListDiscussions(t *testing.T) { }, { name: "filter by category with order", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", "repo": "repo", "category": "DIC_kwDOABC123", @@ -368,7 +368,7 @@ func Test_ListDiscussions(t *testing.T) { }, { name: "order by without direction (should not use ordering)", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", "repo": "repo", "orderBy": "CREATED_AT", @@ -378,7 +378,7 @@ func Test_ListDiscussions(t *testing.T) { }, { name: "direction without order by (should not use ordering)", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", "repo": "repo", "direction": "DESC", @@ -388,7 +388,7 @@ func Test_ListDiscussions(t *testing.T) { }, { name: "repository not found error", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", "repo": "nonexistent-repo", }, @@ -397,7 +397,7 @@ func Test_ListDiscussions(t *testing.T) { }, { name: "list org-level discussions (no repo provided)", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", // repo is not provided, it will default to ".github" }, @@ -511,7 +511,7 @@ func Test_GetDiscussion(t *testing.T) { // Use exact string query that matches implementation output qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,closed,isAnswered,answerChosenAt,url,category{name}}}}" - vars := map[string]interface{}{ + vars := map[string]any{ "owner": "owner", "repo": "repo", "discussionNumber": float64(1), @@ -520,7 +520,7 @@ func Test_GetDiscussion(t *testing.T) { name string response githubv4mock.GQLResponse expectError bool - expected map[string]interface{} + expected map[string]any errContains string }{ { @@ -538,7 +538,7 @@ func Test_GetDiscussion(t *testing.T) { }}, }), expectError: false, - expected: map[string]interface{}{ + expected: map[string]any{ "number": float64(1), "title": "Test Discussion Title", "body": "This is a test discussion", @@ -562,7 +562,7 @@ func Test_GetDiscussion(t *testing.T) { deps := BaseDeps{GQLClient: gqlClient} handler := toolDef.Handler(deps) - reqParams := map[string]interface{}{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)} + reqParams := map[string]any{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)} req := createMCPRequest(reqParams) res, err := handler(ContextWithDeps(context.Background(), deps), &req) text := getTextResult(t, res).Text @@ -574,7 +574,7 @@ func Test_GetDiscussion(t *testing.T) { } require.NoError(t, err) - var out map[string]interface{} + var out map[string]any require.NoError(t, json.Unmarshal([]byte(text), &out)) assert.Equal(t, tc.expected["number"], out["number"]) assert.Equal(t, tc.expected["title"], out["title"]) @@ -583,13 +583,57 @@ func Test_GetDiscussion(t *testing.T) { assert.Equal(t, tc.expected["closed"], out["closed"]) assert.Equal(t, tc.expected["isAnswered"], out["isAnswered"]) // Check category is present - category, ok := out["category"].(map[string]interface{}) + category, ok := out["category"].(map[string]any) require.True(t, ok) assert.Equal(t, "General", category["name"]) }) } } +func Test_GetDiscussionWithStringNumber(t *testing.T) { + // Test that WeakDecode handles string discussionNumber from MCP clients + toolDef := GetDiscussion(translations.NullTranslationHelper) + + qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,closed,isAnswered,answerChosenAt,url,category{name}}}}" + + vars := map[string]any{ + "owner": "owner", + "repo": "repo", + "discussionNumber": float64(1), + } + + matcher := githubv4mock.NewQueryMatcher(qGetDiscussion, vars, githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{"discussion": map[string]any{ + "number": 1, + "title": "Test Discussion Title", + "body": "This is a test discussion", + "url": "https://github.com/owner/repo/discussions/1", + "createdAt": "2025-04-25T12:00:00Z", + "closed": false, + "isAnswered": false, + "category": map[string]any{"name": "General"}, + }}, + })) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + deps := BaseDeps{GQLClient: gqlClient} + handler := toolDef.Handler(deps) + + // Send discussionNumber as a string instead of a number + reqParams := map[string]any{"owner": "owner", "repo": "repo", "discussionNumber": "1"} + 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 out map[string]any + require.NoError(t, json.Unmarshal([]byte(text), &out)) + assert.Equal(t, float64(1), out["number"]) + assert.Equal(t, "Test Discussion Title", out["title"]) +} + func Test_GetDiscussionComments(t *testing.T) { // Verify tool definition and schema toolDef := GetDiscussionComments(translations.NullTranslationHelper) @@ -603,13 +647,14 @@ 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]interface{}{ + vars := map[string]any{ "owner": "owner", "repo": "repo", "discussionNumber": float64(1), @@ -622,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, @@ -642,7 +687,7 @@ func Test_GetDiscussionComments(t *testing.T) { deps := BaseDeps{GQLClient: gqlClient} handler := toolDef.Handler(deps) - reqParams := map[string]interface{}{ + reqParams := map[string]any{ "owner": "owner", "repo": "repo", "discussionNumber": int32(1), @@ -657,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"` @@ -669,10 +717,72 @@ 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{id,body,isAnswer},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_id3", "body": "First comment"}, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 1, + }, + }, + }, + }) + matcher := githubv4mock.NewQueryMatcher(qGetComments, vars, mockResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + deps := BaseDeps{GQLClient: gqlClient} + handler := toolDef.Handler(deps) + + // Send discussionNumber as a string instead of a number + reqParams := map[string]any{ + "owner": "owner", + "repo": "repo", + "discussionNumber": "1", + } + request := createMCPRequest(reqParams) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + require.False(t, result.IsError, "expected no error, got: %s", textContent.Text) + + var out struct { + Comments []map[string]any `json:"comments"` + TotalCount int `json:"totalCount"` + } + 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"]) } func Test_ListDiscussionCategories(t *testing.T) { @@ -693,14 +803,14 @@ func Test_ListDiscussionCategories(t *testing.T) { qListCategories := "query($first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussionCategories(first: $first){nodes{id,name},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" // Variables for repository-level categories - varsRepo := map[string]interface{}{ + varsRepo := map[string]any{ "owner": "owner", "repo": "repo", "first": float64(25), } // Variables for organization-level categories (using .github repo) - varsOrg := map[string]interface{}{ + varsOrg := map[string]any{ "owner": "owner", "repo": ".github", "first": float64(25), @@ -745,8 +855,8 @@ func Test_ListDiscussionCategories(t *testing.T) { tests := []struct { name string - reqParams map[string]interface{} - vars map[string]interface{} + reqParams map[string]any + vars map[string]any mockResponse githubv4mock.GQLResponse expectError bool expectedCount int @@ -754,7 +864,7 @@ func Test_ListDiscussionCategories(t *testing.T) { }{ { name: "list repository-level discussion categories", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -769,7 +879,7 @@ func Test_ListDiscussionCategories(t *testing.T) { }, { name: "list org-level discussion categories (no repo provided)", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", // repo is not provided, it will default to ".github" }, @@ -819,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..8351795327 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -1,7 +1,82 @@ 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" + +// FeatureFlagFileBlame is the feature flag name for the get_file_blame tool, +// which exposes git blame information for a file. It is gated so the extra tool +// is not advertised by default, keeping the tool surface small unless opted in. +const FeatureFlagFileBlame = "file_blame" + +// 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, + FeatureFlagFileBlame, +} + +// 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, + FeatureFlagIssueFields, + FeatureFlagFileBlame, +} + // 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 498c6e4876..acb0da1bcd 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().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 does not auto-enable ifc labels", + enabledFeatures: nil, + insidersMode: true, + unexpectedFlags: []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 0f43ebdf99..9c319176bc 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.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/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -99,7 +100,9 @@ func ListGists(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGistList()) + return result, nil, nil }, ) } @@ -157,7 +160,9 @@ func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGist()) + return result, nil, nil }, ) } diff --git a/pkg/github/gists_test.go b/pkg/github/gists_test.go index 0dd112afb2..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/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -69,7 +69,7 @@ func Test_ListGists(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedGists []*github.Gist expectedErrMsg string @@ -79,7 +79,7 @@ func Test_ListGists(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetGists: mockResponse(t, http.StatusOK, mockGists), }), - requestArgs: map[string]interface{}{}, + requestArgs: map[string]any{}, expectError: false, expectedGists: mockGists, }, @@ -88,7 +88,7 @@ func Test_ListGists(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetUsersGistsByUsername: mockResponse(t, http.StatusOK, mockGists), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "username": "testuser", }, expectError: false, @@ -105,7 +105,7 @@ func Test_ListGists(t *testing.T) { mockResponse(t, http.StatusOK, mockGists), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "since": "2023-01-01T00:00:00Z", "page": float64(2), "perPage": float64(5), @@ -118,7 +118,7 @@ func Test_ListGists(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetGists: mockResponse(t, http.StatusOK, mockGists), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "since": "invalid-date", }, expectError: true, @@ -132,7 +132,7 @@ func Test_ListGists(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Requires authentication"}`)) }), }), - requestArgs: map[string]interface{}{}, + requestArgs: map[string]any{}, expectError: true, expectedErrMsg: "failed to list gists", }, @@ -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, } @@ -219,7 +219,7 @@ func Test_GetGist(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedGists github.Gist expectedErrMsg string @@ -229,7 +229,7 @@ func Test_GetGist(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetGistsByGistID: mockResponse(t, http.StatusOK, mockGist), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "gist_id": "gist1", }, expectError: false, @@ -243,7 +243,7 @@ func Test_GetGist(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Invalid Request"}`)) }), }), - requestArgs: map[string]interface{}{}, + requestArgs: map[string]any{}, expectError: true, expectedErrMsg: "missing required parameter: gist_id", }, @@ -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, } @@ -332,7 +332,7 @@ func Test_CreateGist(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedErrMsg string expectedGist *github.Gist @@ -342,7 +342,7 @@ func Test_CreateGist(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostGists: mockResponse(t, http.StatusCreated, createdGist), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "filename": "test.go", "content": "package main\n\nfunc main() {\n\tfmt.Println(\"Hello, Gist!\")\n}", "description": "Test Gist", @@ -354,7 +354,7 @@ func Test_CreateGist(t *testing.T) { { name: "missing required filename", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "content": "test content", "description": "Test Gist", }, @@ -364,7 +364,7 @@ func Test_CreateGist(t *testing.T) { { name: "missing required content", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "filename": "test.go", "description": "Test Gist", }, @@ -379,7 +379,7 @@ func Test_CreateGist(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Requires authentication"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "filename": "test.go", "content": "package main", "description": "Test Gist", @@ -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, } @@ -471,7 +471,7 @@ func Test_UpdateGist(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedErrMsg string expectedGist *github.Gist @@ -481,7 +481,7 @@ func Test_UpdateGist(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchGistsByGistID: mockResponse(t, http.StatusOK, updatedGist), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "gist_id": "existing-gist-id", "filename": "updated.go", "content": "package main\n\nfunc main() {\n\tfmt.Println(\"Updated Gist!\")\n}", @@ -493,7 +493,7 @@ func Test_UpdateGist(t *testing.T) { { name: "missing required gist_id", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "filename": "updated.go", "content": "updated content", "description": "Updated Test Gist", @@ -504,7 +504,7 @@ func Test_UpdateGist(t *testing.T) { { name: "missing required filename", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "gist_id": "existing-gist-id", "content": "updated content", "description": "Updated Test Gist", @@ -515,7 +515,7 @@ func Test_UpdateGist(t *testing.T) { { name: "missing required content", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "gist_id": "existing-gist-id", "filename": "updated.go", "description": "Updated Test Gist", @@ -531,7 +531,7 @@ func Test_UpdateGist(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "gist_id": "nonexistent-gist-id", "filename": "updated.go", "content": "package main", @@ -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 ec7159b9bc..bf88aad770 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -7,11 +7,12 @@ 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/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -171,7 +172,13 @@ func GetRepositoryTree(t translations.TranslationHelperFunc) inventory.ServerToo return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // The repository tree exposes committed file structure; in public + // repos anyone can land content via a PR (untrusted), in private + // repos only collaborators can (trusted). Confidentiality follows + // repo visibility. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents) + return result, nil, nil }, ) } diff --git a/pkg/github/git_test.go b/pkg/github/git_test.go index d60aed0929..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/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -63,7 +63,7 @@ func Test_GetRepositoryTree(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedErrMsg string }{ @@ -73,7 +73,7 @@ func Test_GetRepositoryTree(t *testing.T) { GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, mockRepo), GetReposGitTreesByOwnerByRepoByTree: mockResponse(t, http.StatusOK, mockTree), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -84,7 +84,7 @@ func Test_GetRepositoryTree(t *testing.T) { GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, mockRepo), GetReposGitTreesByOwnerByRepoByTree: mockResponse(t, http.StatusOK, mockTree), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path_filter": "src/", @@ -98,7 +98,7 @@ func Test_GetRepositoryTree(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "nonexistent", }, @@ -114,7 +114,7 @@ func Test_GetRepositoryTree(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -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, } @@ -149,7 +149,7 @@ func Test_GetRepositoryTree(t *testing.T) { textContent := getTextResult(t, result) // Parse the JSON response - var treeResponse map[string]interface{} + var treeResponse map[string]any err := json.Unmarshal([]byte(textContent.Text), &treeResponse) require.NoError(t, err) @@ -163,9 +163,9 @@ func Test_GetRepositoryTree(t *testing.T) { // Check filtering if path_filter was provided if pathFilter, exists := tc.requestArgs["path_filter"]; exists { - tree := treeResponse["tree"].([]interface{}) + tree := treeResponse["tree"].([]any) for _, entry := range tree { - entryMap := entry.(map[string]interface{}) + entryMap := entry.(map[string]any) path := entryMap["path"].(string) assert.True(t, strings.HasPrefix(path, pathFilter.(string)), "Path %s should start with filter %s", path, pathFilter) diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go new file mode 100644 index 0000000000..4a274ac318 --- /dev/null +++ b/pkg/github/granular_tools_test.go @@ -0,0 +1,2027 @@ +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/http/headers" + transportpkg "github.com/github/github-mcp-server/pkg/http/transport" + "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: "label confidence is normalized", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "bug", "confidence": " high\t"}, + }, + }, + expectedReq: map[string]any{ + "labels": []any{ + map[string]any{"name": "bug", "confidence": "HIGH"}, + }, + }, + }, + { + 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", + }, + }, + }, + { + name: "type confidence is normalized", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "bug", + "confidence": " medium ", + }, + expectedReq: map[string]any{ + "type": map[string]any{ + "value": "bug", + "confidence": "MEDIUM", + }, + }, + }, + } + + 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("confidence is sent when supplied", 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, getTextResult(t, result).Text) + }) + + 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) + }) + + t.Run("sends GraphQL-Features: update_issue_suggestions header on mutation", 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")), + }, + }, + }, + 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", + }, + }, + }), + ), + } + + // Build a transport chain matching production: GraphQLFeaturesTransport + // wraps a header-capturing spy, which forwards to the mock's RoundTripper. + // This verifies the mutation request sets the update_issue_suggestions + // feature flag so the rationale/suggest input fields are accepted. + mockClient := githubv4mock.NewMockedHTTPClient(matchers...) + spy := &headerCaptureTransport{inner: mockClient.Transport} + httpClient := &http.Client{ + Transport: &transportpkg.GraphQLFeaturesTransport{Transport: spy}, + } + gqlClient := githubv4.NewClient(httpClient) + 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) + require.False(t, result.IsError, getTextResult(t, result).Text) + // The last request captured is the mutation; the preceding issue ID + // query does not require the feature flag. + assert.Equal(t, "update_issue_suggestions", spy.captured.Get(headers.GraphQLFeaturesHeader)) + }) +} diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 0bb73008ec..2ad1736794 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -2,6 +2,7 @@ package github import ( "bytes" + "context" "encoding/json" "io" "net/http" @@ -9,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" @@ -20,6 +22,7 @@ import ( const ( // User endpoints GetUser = "GET /user" + GetUsersByUsername = "GET /users/{username}" GetUserStarred = "GET /user/starred" GetUsersGistsByUsername = "GET /users/{username}/gists" GetUsersStarredByUsername = "GET /users/{username}/starred" @@ -38,6 +41,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}" @@ -50,6 +54,7 @@ const ( PostReposGitTreesByOwnerByRepo = "POST /repos/{owner}/{repo}/git/trees" GetReposCommitsStatusByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/status" GetReposCommitsStatusesByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/statuses" + GetReposCommitsCheckRunsByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/check-runs" // Issues endpoints GetReposIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}" @@ -65,6 +70,7 @@ const ( // Pull request endpoints GetReposPullsByOwnerByRepo = "GET /repos/{owner}/{repo}/pulls" GetReposPullsByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}" + GetReposPullsCommitsByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}/commits" GetReposPullsFilesByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}/files" GetReposPullsReviewsByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews" PostReposPullsByOwnerByRepo = "POST /repos/{owner}/{repo}/pulls" @@ -72,6 +78,7 @@ const ( PutReposPullsMergeByOwnerByRepoByPullNumber = "PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge" PutReposPullsUpdateBranchByOwnerByRepoByPullNumber = "PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch" PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber = "POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers" + PostReposPullsCommentsByOwnerByRepoByPullNumber = "POST /repos/{owner}/{repo}/pulls/{pull_number}/comments" // Notifications endpoints GetNotifications = "GET /notifications" @@ -95,6 +102,9 @@ const ( GetReposReleasesLatestByOwnerByRepo = "GET /repos/{owner}/{repo}/releases/latest" GetReposReleasesTagsByOwnerByRepoByTag = "GET /repos/{owner}/{repo}/releases/tags/{tag}" + // Code quality endpoints + GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber = "GET /repos/{owner}/{repo}/code-quality/findings/{finding_number}" + // Code scanning endpoints GetReposCodeScanningAlertsByOwnerByRepo = "GET /repos/{owner}/{repo}/code-scanning/alerts" GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber = "GET /repos/{owner}/{repo}/code-scanning/alerts/{alert_number}" @@ -135,6 +145,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 @@ -175,6 +186,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 { @@ -216,9 +243,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 { @@ -243,13 +276,19 @@ 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) } } // mockResponse is a helper function to create a mock HTTP response handler // that returns a specified status code and marshaled body. -func mockResponse(t *testing.T, code int, body interface{}) http.HandlerFunc { +func mockResponse(t *testing.T, code int, body any) http.HandlerFunc { t.Helper() return func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(code) @@ -270,9 +309,9 @@ func mockResponse(t *testing.T, code int, body interface{}) http.HandlerFunc { // createMCPRequest is a helper function to create a MCP request with the given arguments. func createMCPRequest(args any) mcp.CallToolRequest { // convert args to map[string]interface{} and serialize to JSON - argsMap, ok := args.(map[string]interface{}) + argsMap, ok := args.(map[string]any) if !ok { - argsMap = make(map[string]interface{}) + argsMap = make(map[string]any) } argsJSON, err := json.Marshal(argsMap) @@ -289,6 +328,58 @@ func createMCPRequest(args any) mcp.CallToolRequest { } } +// Well-known MCP client names used in tests. +const ( + ClientNameVSCodeInsiders = "Visual Studio Code - Insiders" + ClientNameVSCode = "Visual Studio Code" +) + +// createMCPRequestWithSession creates a CallToolRequest with a ServerSession +// that has the given client name in its InitializeParams. When withUI is true +// the session advertises MCP Apps UI support via the capability extension. +func createMCPRequestWithSession(t *testing.T, clientName string, withUI bool, args any) mcp.CallToolRequest { + t.Helper() + + argsMap, ok := args.(map[string]any) + if !ok { + argsMap = make(map[string]any) + } + argsJSON, err := json.Marshal(argsMap) + require.NoError(t, err) + + srv := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) + + caps := &mcp.ClientCapabilities{} + if withUI { + caps.AddExtension("io.modelcontextprotocol/ui", map[string]any{ + "mimeTypes": []string{"text/html;profile=mcp-app"}, + }) + } + + st, _ := mcp.NewInMemoryTransports() + session, err := srv.Connect(context.Background(), st, &mcp.ServerSessionOptions{ + State: &mcp.ServerSessionState{ + InitializeParams: &mcp.InitializeParams{ + ClientInfo: &mcp.Implementation{Name: clientName}, + Capabilities: caps, + }, + }, + }) + require.NoError(t, err) + + // Close the unused client-side transport and session + t.Cleanup(func() { + _ = session.Close() + }) + + return mcp.CallToolRequest{ + Session: session, + Params: &mcp.CallToolParamsRaw{ + Arguments: json.RawMessage(argsJSON), + }, + } +} + // getTextResult is a helper function that returns a text result from a tool call. func getTextResult(t *testing.T, result *mcp.CallToolResult) *mcp.TextContent { t.Helper() @@ -312,16 +403,16 @@ func getErrorResult(t *testing.T, result *mcp.CallToolResult) *mcp.TextContent { func TestOptionalParamOK(t *testing.T) { tests := []struct { name string - args map[string]interface{} + args map[string]any paramName string - expectedVal interface{} + expectedVal any expectedOk bool expectError bool errorMsg string }{ { name: "present and correct type (string)", - args: map[string]interface{}{"myParam": "hello"}, + args: map[string]any{"myParam": "hello"}, paramName: "myParam", expectedVal: "hello", expectedOk: true, @@ -329,7 +420,7 @@ func TestOptionalParamOK(t *testing.T) { }, { name: "present and correct type (bool)", - args: map[string]interface{}{"myParam": true}, + args: map[string]any{"myParam": true}, paramName: "myParam", expectedVal: true, expectedOk: true, @@ -337,7 +428,7 @@ func TestOptionalParamOK(t *testing.T) { }, { name: "present and correct type (number)", - args: map[string]interface{}{"myParam": float64(123)}, + args: map[string]any{"myParam": float64(123)}, paramName: "myParam", expectedVal: float64(123), expectedOk: true, @@ -345,7 +436,7 @@ func TestOptionalParamOK(t *testing.T) { }, { name: "present but wrong type (string expected, got bool)", - args: map[string]interface{}{"myParam": true}, + args: map[string]any{"myParam": true}, paramName: "myParam", expectedVal: "", // Zero value for string expectedOk: true, // ok is true because param exists @@ -354,7 +445,7 @@ func TestOptionalParamOK(t *testing.T) { }, { name: "present but wrong type (bool expected, got string)", - args: map[string]interface{}{"myParam": "true"}, + args: map[string]any{"myParam": "true"}, paramName: "myParam", expectedVal: false, // Zero value for bool expectedOk: true, // ok is true because param exists @@ -363,7 +454,7 @@ func TestOptionalParamOK(t *testing.T) { }, { name: "parameter not present", - args: map[string]interface{}{"anotherParam": "value"}, + args: map[string]any{"anotherParam": "value"}, paramName: "myParam", expectedVal: "", // Zero value for string expectedOk: false, @@ -531,7 +622,7 @@ func matchPath(pattern, path string) bool { if len(pathParts) < len(patternParts)-1 { return false } - for i := 0; i < len(patternParts)-1; i++ { + for i := range len(patternParts) - 1 { if strings.HasPrefix(patternParts[i], "{") && strings.HasSuffix(patternParts[i], "}") { continue // Path parameter matches anything } diff --git a/pkg/github/ifc_labels.go b/pkg/github/ifc_labels.go new file mode 100644 index 0000000000..9ab46b5136 --- /dev/null +++ b/pkg/github/ifc_labels.go @@ -0,0 +1,171 @@ +package github + +import ( + "context" + + "github.com/github/github-mcp-server/pkg/ifc" + "github.com/google/go-github/v87/github" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// setIFCLabel writes the given IFC security label into a tool result's _meta +// under the "ifc" key, allocating the Meta map if necessary. +func setIFCLabel(r *mcp.CallToolResult, label ifc.SecurityLabel) { + if r.Meta == nil { + r.Meta = mcp.Meta{} + } + r.Meta["ifc"] = label +} + +func shouldAttachIFCLabel(ctx context.Context, deps ToolDependencies, r *mcp.CallToolResult) bool { + return r != nil && !r.IsError && deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) +} + +// attachStaticIFCLabel attaches a fixed IFC label to a successful tool result +// when IFC labels are enabled. It is used by tools whose label does not depend +// on any repository visibility lookup (e.g. security alerts, global +// advisories, team membership, notification subjects). +// +// Error results are left untouched, and the label is omitted entirely when the +// IFC feature flag is disabled. +func attachStaticIFCLabel(ctx context.Context, deps ToolDependencies, r *mcp.CallToolResult, label ifc.SecurityLabel) *mcp.CallToolResult { + if !shouldAttachIFCLabel(ctx, deps, r) { + return r + } + setIFCLabel(r, label) + return r +} + +// attachRepoVisibilityIFCLabel attaches an IFC label derived from a single +// repository's visibility to a successful tool result when IFC labels are +// enabled. The concrete label is produced by labelFn, which receives whether +// the repository is private. +// +// The repository visibility is resolved via FetchRepoIsPrivate. Consistent +// with the other IFC-labeled tools, if the visibility lookup fails the label +// is omitted rather than risking a misclassification. Error results and the +// disabled-feature case are left untouched. +func attachRepoVisibilityIFCLabel( + ctx context.Context, + deps ToolDependencies, + client *github.Client, + owner, repo string, + r *mcp.CallToolResult, + labelFn func(isPrivate bool) ifc.SecurityLabel, +) *mcp.CallToolResult { + if !shouldAttachIFCLabel(ctx, deps, r) { + return r + } + isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo) + if err != nil { + return r + } + setIFCLabel(r, labelFn(isPrivate)) + return r +} + +// ifcSearchPostProcessOption returns a searchOption that attaches IFC labels to +// a multi-repository search result. The feature-flag check is centralized here +// (mirroring the attach* helpers above) rather than in each search tool +// handler: when IFC labels are disabled it returns a no-op option, so callers +// can pass it unconditionally to searchHandler. +func ifcSearchPostProcessOption(ctx context.Context, deps ToolDependencies) searchOption { + if !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + return func(*searchConfig) {} + } + return withSearchPostProcess(searchIssuesIFCPostProcess(deps)) +} + +// attachRepoVisibilityIFCLabelLazy is like attachRepoVisibilityIFCLabel but +// resolves the REST client itself, only when IFC labels are enabled. It is used +// by tools whose handler holds a GraphQL client (or no client yet) and would +// otherwise have to acquire a REST client solely to compute the label. The +// feature-flag check is centralized here so callers can invoke it +// unconditionally; if the client cannot be obtained or the visibility lookup +// fails, the label is omitted rather than risking a misclassification. +func attachRepoVisibilityIFCLabelLazy( + ctx context.Context, + deps ToolDependencies, + owner, repo string, + r *mcp.CallToolResult, + labelFn func(isPrivate bool) ifc.SecurityLabel, +) *mcp.CallToolResult { + if !shouldAttachIFCLabel(ctx, deps, r) { + return r + } + client, err := deps.GetClient(ctx) + if err != nil { + return r + } + return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, labelFn) +} + +// attachJoinedIFCLabel attaches an IFC label computed by joining a set of +// per-item visibilities (true == private) when IFC labels are enabled. joinFn +// is the lattice join for the relevant item kind (e.g. ifc.LabelSearchIssues or +// ifc.LabelProjectList). The visibility slice is cheap to build from an +// already-fetched response, so callers may construct it unconditionally and let +// this helper own the feature-flag gate. +func attachJoinedIFCLabel( + ctx context.Context, + deps ToolDependencies, + r *mcp.CallToolResult, + visibilities []bool, + joinFn func([]bool) ifc.SecurityLabel, +) *mcp.CallToolResult { + if !shouldAttachIFCLabel(ctx, deps, r) { + return r + } + setIFCLabel(r, joinFn(visibilities)) + return r +} + +func attachProjectVisibilityIFCLabel( + ctx context.Context, + deps ToolDependencies, + r *mcp.CallToolResult, + isPrivate bool, + labelFn func(isPrivate bool) ifc.SecurityLabel, +) *mcp.CallToolResult { + if !shouldAttachIFCLabel(ctx, deps, r) { + return r + } + setIFCLabel(r, labelFn(isPrivate)) + return r +} + +// newRepoVisibilityIFCLabeler returns a closure that attaches a repo-visibility +// IFC label to a tool result, for handlers that have several return paths and +// want to label each one. The returned function owns the feature-flag gate (so +// callers invoke it unconditionally) and caches the repository visibility +// lookup across calls, so a handler that returns from many branches only pays +// for one FetchRepoIsPrivate call. A failed visibility lookup is not cached, so +// a later return path can retry; on persistent failure the label is omitted +// rather than risking a misclassification. +func newRepoVisibilityIFCLabeler( + ctx context.Context, + deps ToolDependencies, + client *github.Client, + owner, repo string, + labelFn func(isPrivate bool) ifc.SecurityLabel, +) func(*mcp.CallToolResult) *mcp.CallToolResult { + var ( + known bool + isPrivate bool + ) + return func(r *mcp.CallToolResult) *mcp.CallToolResult { + if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + return r + } + if !known { + p, err := FetchRepoIsPrivate(ctx, client, owner, repo) + if err != nil { + return r + } + isPrivate = p + known = true + } + setIFCLabel(r, labelFn(isPrivate)) + return r + } +} diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go new file mode 100644 index 0000000000..27a4a09c5d --- /dev/null +++ b/pkg/github/issue_fields.go @@ -0,0 +1,275 @@ +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/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/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 + } + + result := utils.NewToolResultText(string(r)) + // Issue field definitions are repo/org structural metadata + // (trusted). When scoped to a specific repo, confidentiality + // follows that repo's visibility; for an org-level lookup (no + // repo) it is conservatively treated as private. + if repo == "" { + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepoMetadata(true)) + } else { + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) + } + return result, 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 62e1a0bacf..f98982f1e6 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -6,19 +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/lockdown" - "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/sanitize" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -37,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" @@ -48,7 +57,7 @@ const ( // When duplicateOf is non-zero, it fetches both the main issue and duplicate issue IDs in a single query. func fetchIssueIDs(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int, duplicateOf int) (githubv4.ID, githubv4.ID, error) { // Build query variables common to both cases - vars := map[string]interface{}{ + vars := map[string]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers @@ -65,7 +74,7 @@ func fetchIssueIDs(ctx context.Context, gqlClient *githubv4.Client, owner, repo } if err := gqlClient.Query(ctx, &query, vars); err != nil { - return "", "", fmt.Errorf("failed to get issue ID") + return "", "", fmt.Errorf("failed to get issue ID: %w", err) } return query.Repository.Issue.ID, "", nil @@ -87,7 +96,7 @@ func fetchIssueIDs(ctx context.Context, gqlClient *githubv4.Client, owner, repo vars["duplicateOf"] = githubv4.Int(duplicateOf) // #nosec G115 - issue numbers are always small positive integers if err := gqlClient.Query(ctx, &query, vars); err != nil { - return "", "", fmt.Errorf("failed to get issue ID") + return "", "", fmt.Errorf("failed to get issue ID: %w", err) } return query.Repository.Issue.ID, query.Repository.DuplicateIssue.ID, nil @@ -105,6 +114,370 @@ 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 @@ -128,11 +501,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 { @@ -149,48 +526,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: @@ -204,30 +605,120 @@ func getIssueQueryType(hasLabels bool, hasSince bool) any { } } -func fragmentToIssue(fragment IssueFragment) *github.Issue { - // Convert GraphQL labels to GitHub API labels format - var foundLabels []*github.Label - for _, labelNode := range fragment.Labels.Nodes { - foundLabels = append(foundLabels, &github.Label{ - Name: github.Ptr(string(labelNode.Name)), - NodeID: github.Ptr(string(labelNode.ID)), - Description: github.Ptr(string(labelNode.Description)), - }) +// --- 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 + + 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 +} - return &github.Issue{ - Number: github.Ptr(int(fragment.Number)), - Title: github.Ptr(sanitize.Sanitize(string(fragment.Title))), - CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time}, - UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time}, - User: &github.User{ - Login: github.Ptr(string(fragment.Author.Login)), - }, - State: github.Ptr(string(fragment.State)), - ID: github.Ptr(fragment.DatabaseID), - Body: github.Ptr(sanitize.Sanitize(string(fragment.Body))), - Labels: foundLabels, - Comments: github.Ptr(int(fragment.Comments.TotalCount)), +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{} } } @@ -310,26 +801,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 := newRepoVisibilityIFCLabeler(ctx, deps, client, owner, repo, ifc.LabelRepoUserContent) + switch method { case "get": - result, err := GetIssue(ctx, client, deps.GetRepoAccessCache(), owner, repo, issueNumber, deps.GetFlags()) - return result, nil, err + result, err := GetIssue(ctx, client, deps, owner, repo, issueNumber) + return attachIFC(result), nil, err case "get_comments": - result, err := GetIssueComments(ctx, client, deps.GetRepoAccessCache(), owner, repo, issueNumber, pagination, deps.GetFlags()) - return result, nil, err + result, err := GetIssueComments(ctx, client, deps, owner, repo, issueNumber, pagination) + return attachIFC(result), nil, err case "get_sub_issues": - result, err := GetSubIssues(ctx, client, deps.GetRepoAccessCache(), owner, repo, issueNumber, pagination, deps.GetFlags()) - return result, nil, err + result, err := GetSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination) + 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 } }) } -func GetIssue(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner string, repo string, issueNumber int, flags FeatureFlags) (*mcp.CallToolResult, error) { +func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { + cache, err := deps.GetRepoAccessCache(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get repo access cache: %w", err) + } + flags := deps.GetFlags(ctx) + issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber) if err != nil { return nil, fmt.Errorf("failed to get issue: %w", err) @@ -370,15 +872,32 @@ func GetIssue(ctx context.Context, client *github.Client, cache *lockdown.RepoAc } } - r, err := json.Marshal(issue) - if err != nil { - return nil, fmt.Errorf("failed to marshal issue: %w", err) + 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 utils.NewToolResultText(string(r)), nil + return MarshalledTextResult(minimalIssue), nil } -func GetIssueComments(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner string, repo string, issueNumber int, pagination PaginationParams, flags FeatureFlags) (*mcp.CallToolResult, error) { +func GetIssueComments(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber 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) + } + flags := deps.GetFlags(ctx) + opts := &github.IssueListCommentsOptions{ ListOptions: github.ListOptions{ Page: pagination.Page, @@ -424,20 +943,24 @@ func GetIssueComments(ctx context.Context, client *github.Client, cache *lockdow comments = filteredComments } - r, err := json.Marshal(comments) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + minimalComments := make([]MinimalIssueComment, 0, len(comments)) + for _, comment := range comments { + minimalComments = append(minimalComments, convertToMinimalIssueComment(comment)) } - return utils.NewToolResultText(string(r)), nil + return MarshalledTextResult(minimalComments), nil } -func GetSubIssues(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner string, repo string, issueNumber int, pagination PaginationParams, featureFlags FeatureFlags) (*mcp.CallToolResult, error) { - opts := &github.IssueListOptions{ - ListOptions: github.ListOptions{ - Page: pagination.Page, - PerPage: pagination.PerPage, - }, +func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber 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) + } + featureFlags := deps.GetFlags(ctx) + + opts := &github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, } subIssues, resp, err := client.SubIssue.ListByIssue(ctx, owner, repo, int64(issueNumber), opts) @@ -542,16 +1065,16 @@ 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. +// ListIssueTypes creates a tool to list defined issue types for an organization or repository. +// This can be used to understand supported issue type values for creating or updating issues. func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issue_types", - Description: t("TOOL_LIST_ISSUE_TYPES_FOR_ORG", "List supported issue types for repository owner (organization)."), + Description: t("TOOL_LIST_ISSUE_TYPES_FOR_ORG", "List supported issue types for a repository or its owner organization. When repo is omitted, returns org-level issue types directly."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_ISSUE_TYPES_USER_TITLE", "List available issue types"), ReadOnlyHint: true, @@ -561,23 +1084,63 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", - Description: "The organization owner of the repository", + Description: "The account owner of the repository or organization.", + }, + "repo": { + Type: "string", + Description: "The name of the repository. When provided, returns issue types for this specific repository. When omitted, returns org-level issue types directly.", }, }, Required: []string{"owner"}, }, }, - []scopes.Scope{scopes.ReadOrg}, + []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 + } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } + + if repo != "" { + apiURL := fmt.Sprintf("repos/%s/%s/issue-types", owner, repo) + req, err := client.NewRequest(ctx, "GET", apiURL, nil) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + } + var issueTypes []*github.IssueType + resp, err := client.Do(req, &issueTypes) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list issue types", 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 list issue types", resp, body), nil, nil + } + + r, err := json.Marshal(issueTypes) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal issue types", err), nil, nil + } + + result := utils.NewToolResultText(string(r)) + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) + return result, nil, nil + } + issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) if err != nil { return utils.NewToolResultErrorFromErr("failed to list issue types", err), nil, nil @@ -597,7 +1160,13 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultErrorFromErr("failed to marshal issue types", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Issue types are org-defined structural metadata (trusted, not + // attacker-authored). They are scoped to an organization rather + // than a single repo, so confidentiality is conservatively treated + // as private (restricted to org members). + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepoMetadata(true)) + return result, nil, nil }) } @@ -609,7 +1178,7 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool Name: "add_issue_comment", Description: t("TOOL_ADD_ISSUE_COMMENT_DESCRIPTION", "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_ADD_ISSUE_COMMENT_USER_TITLE", "Add comment to issue"), + Title: t("TOOL_ADD_ISSUE_COMMENT_USER_TITLE", "Add comment to issue or pull request"), ReadOnlyHint: false, }, InputSchema: &jsonschema.Schema{ @@ -676,7 +1245,12 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create comment", resp, body), nil, nil } - r, err := json.Marshal(createdComment) + minimalResponse := MinimalResponse{ + ID: fmt.Sprintf("%d", createdComment.GetID()), + URL: createdComment.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } @@ -687,7 +1261,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", @@ -797,6 +1371,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) { @@ -830,7 +1406,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) { @@ -970,35 +1545,645 @@ 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") + result, err := searchIssuesHandler(ctx, deps, args, ifcSearchPostProcessOption(ctx, deps)) return result, nil, err }) } -// IssueWrite creates a tool to create a new or update an existing issue in a GitHub repository. -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, - }, - 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": { +// 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 + } + + 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. 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": {}, + "issue_fields": {}, + "state": {}, + "state_reason": {}, + "duplicate_of": {}, + "show_ui": {}, + "_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, milestones or issue types). 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 +} + +// issueWriteAwaitingFormResult builds the "awaiting form submission" stub +// returned when issue_write hands off to the MCP App form. The body is shared +// by IssueWrite and LegacyIssueWrite. The result is marked IsError=true so +// agents that bail on error don't claim success or chain dependent tool calls +// while the user is still interacting with the form; the host renders the UI +// regardless because rendering is keyed off the tool's _meta.ui resourceUri. +func issueWriteAwaitingFormResult(method, owner, repo string, issueNumber int) *mcp.CallToolResult { + var msg string + if method == "update" { + msg = fmt.Sprintf( + "An interactive form has been shown to the user for editing issue #%d in %s/%s. "+ + "STOP — do not call any other tools, do not respond as if the issue was updated, "+ + "and do not claim the operation succeeded. The issue has NOT been updated yet; "+ + "only the form was rendered. Wait silently for the user to review and click Submit. "+ + "When they do, the real result will be delivered to your context automatically.", + issueNumber, owner, repo, + ) + } else { + msg = fmt.Sprintf( + "An interactive form has been shown to the user for creating a new issue in %s/%s. "+ + "STOP — do not call any other tools, do not respond as if the issue was created, "+ + "and do not claim the operation succeeded. The issue has NOT been created yet; "+ + "only the form was rendered. Wait silently for the user to review and click Submit. "+ + "When they do, the real result will be delivered to your context automatically.", + owner, repo, + ) + } + return utils.NewToolResultAwaitingFormSubmission(msg) +} + +// 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/pull request"), + 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 issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner 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"}, + }, + }, + // show_ui is hidden from clients that do not advertise MCP App + // UI support. The strip happens per-request in + // inventory.ToolsForRegistration; it is present in the static + // schema (and therefore in toolsnaps and the feature-flag / + // insiders docs) so the UI-capable surface is fully + // documented. It is intentionally not in the main README, + // which renders the stripped (non-UI) schema. + "show_ui": { + Type: "boolean", + Description: "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, issue_fields, or state changes) and the user has already confirmed the action.", + }, + }, + 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), + // - the caller explicitly asked to skip the UI (show_ui=false), 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") + showUI, err := OptionalBoolParamWithDefault(args, "show_ui", true) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !issueWriteHasNonFormParams(args) { + issueNumber := 0 + if method == "update" { + n, numErr := RequiredInt(args, "issue_number") + if numErr != nil { + return utils.NewToolResultError("issue_number is required for update method"), nil, nil + } + issueNumber = n + } + return issueWriteAwaitingFormResult(method, owner, repo, issueNumber), 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/pull request"), + 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", }, @@ -1038,7 +2223,7 @@ Options are: }, "type": { Type: "string", - Description: "Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter.", + Description: "Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter.", }, "state": { Type: "string", @@ -1054,12 +2239,23 @@ Options are: Type: "number", Description: "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", }, + // show_ui is hidden from clients that do not advertise MCP App + // UI support. The strip happens per-request in + // inventory.ToolsForRegistration; it is present in the static + // schema (and therefore in toolsnaps and the feature-flag / + // insiders docs) so the UI-capable surface is fully + // documented. It is intentionally not in the main README, + // which renders the stripped (non-UI) schema. + "show_ui": { + Type: "boolean", + Description: "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action.", + }, }, Required: []string{"method", "owner", "repo"}, }, }, []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + 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 @@ -1073,6 +2269,32 @@ Options are: 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), + // - the caller explicitly asked to skip the UI (show_ui=false), 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") + showUI, err := OptionalBoolParamWithDefault(args, "show_ui", true) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !issueWriteHasNonFormParams(args) { + issueNumber := 0 + if method == "update" { + n, numErr := RequiredInt(args, "issue_number") + if numErr != nil { + return utils.NewToolResultError("issue_number is required for update method"), nil, nil + } + issueNumber = n + } + return issueWriteAwaitingFormResult(method, owner, repo, issueNumber), nil, nil + } + title, err := OptionalParam[string](args, "title") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1089,12 +2311,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") @@ -1144,32 +2370,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 { @@ -1212,7 +2444,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{} @@ -1225,11 +2474,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 } @@ -1241,6 +2490,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, @@ -1305,40 +2579,312 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 } `graphql:"closeIssue(input: $input)"` } - stateReasonValue := getCloseStateReason(stateReason) - closeInput := CloseIssueInput{ - IssueID: issueID, - StateReason: &stateReasonValue, + stateReasonValue := getCloseStateReason(stateReason) + closeInput := CloseIssueInput{ + IssueID: issueID, + StateReason: &stateReasonValue, + } + + // Set duplicate issue ID if needed + if stateReason == "duplicate" { + closeInput.DuplicateIssueID = &duplicateIssueID + } + + err = gqlClient.Mutate(ctx, &mutation, closeInput, nil) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to close issue", err), nil + } + } + } + + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + ID: fmt.Sprintf("%d", updatedIssue.GetID()), + URL: updatedIssue.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil +} + +// 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", + 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)", + }, + "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) + + 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 + } + + // Set optional parameters if provided + state, err := OptionalParam[string](args, "state") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Normalize and filter by state + 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} + } + + // Get labels + 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 + } + + // Normalize and validate orderBy + orderBy = strings.ToUpper(orderBy) + switch orderBy { + case "CREATED_AT", "UPDATED_AT", "COMMENTS": + // Valid, keep as is + default: + orderBy = "CREATED_AT" + } + + // Normalize and validate direction + direction = strings.ToUpper(direction) + switch direction { + case "ASC", "DESC": + // Valid, keep as is + default: + direction = "DESC" + } + + since, err := OptionalParam[string](args, "since") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // There are two optional parameters: since and labels. + 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 + + 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 { + return nil, nil, err + } + + // Check if someone tried to use page-based pagination instead of cursor-based + 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 + } + + // Check if pagination parameters were explicitly provided + _, perPageProvided := args["perPage"] + paginationExplicit := perPageProvided + + paginationParams, err := pagination.ToGraphQLParams() + if err != nil { + return nil, nil, err + } + + // Use default of 30 if pagination was not explicitly provided + 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 + } + + // 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), + "issueFieldValues": fieldFilters, } - // Set duplicate issue ID if needed - if stateReason == "duplicate" { - closeInput.DuplicateIssueID = &duplicateIssueID + if paginationParams.After != nil { + vars["after"] = githubv4.String(*paginationParams.After) + } else { + // Used within query, therefore must be set to nil and provided as $after + vars["after"] = (*githubv4.String)(nil) } - err = gqlClient.Mutate(ctx, &mutation, closeInput, nil) - if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to close issue", err), nil + // Ensure optional parameters are set + if hasLabels { + // Use query with labels filtering - convert string labels to githubv4.String slice + labelStrings := make([]githubv4.String, len(labels)) + for i, label := range labels { + labelStrings[i] = githubv4.String(label) + } + vars["labels"] = labelStrings } - } - } - // Return minimal response with just essential information - minimalResponse := MinimalResponse{ - ID: fmt.Sprintf("%d", updatedIssue.GetID()), - URL: updatedIssue.GetHTMLURL(), - } + if hasSince { + vars["since"] = githubv4.DateTime{Time: sinceTime} + } - r, err := json.Marshal(minimalResponse) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + issueQuery := getIssueQueryType(hasLabels, hasSince) + // 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", + err, + ), nil, nil + } - return utils.NewToolResultText(string(r)), nil + var resp MinimalIssuesResponse + var isPrivate bool + if queryResult, ok := issueQuery.(IssueQueryResult); ok { + resp = convertToMinimalIssuesResponse(queryResult.GetIssueFragment()) + isPrivate = queryResult.GetIsPrivate() + } + + result := MarshalledTextResult(resp) + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelListIssues(isPrivate)) + return result, nil, nil + }) + st.FeatureFlagEnable = FeatureFlagIssueFields + return st } -// ListIssues creates a tool to list and filter repository issues -func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { +// 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{ @@ -1381,7 +2927,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } WithCursorPagination(schema) - return NewTool( + st := NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issues", @@ -1403,16 +2949,12 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(err.Error()), nil, nil } - // Set optional parameters if provided state, err := OptionalParam[string](args, "state") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - - // Normalize and filter by state state = strings.ToUpper(state) var states []githubv4.IssueState - switch state { case "OPEN", "CLOSED": states = []githubv4.IssueState{githubv4.IssueState(state)} @@ -1420,7 +2962,6 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { states = []githubv4.IssueState{githubv4.IssueStateOpen, githubv4.IssueStateClosed} } - // Get labels labels, err := OptionalStringArrayParam(args, "labels") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1430,26 +2971,19 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { 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 } - - // Normalize and validate orderBy orderBy = strings.ToUpper(orderBy) switch orderBy { case "CREATED_AT", "UPDATED_AT", "COMMENTS": - // Valid, keep as is default: orderBy = "CREATED_AT" } - - // Normalize and validate direction direction = strings.ToUpper(direction) switch direction { case "ASC", "DESC": - // Valid, keep as is default: direction = "DESC" } @@ -1458,8 +2992,6 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - - // There are two optional parameters: since and labels. var sinceTime time.Time var hasSince bool if since != "" { @@ -1471,27 +3003,19 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } hasLabels := len(labels) > 0 - // Get pagination parameters and convert to GraphQL format pagination, err := OptionalCursorPaginationParams(args) if err != nil { return nil, nil, err } - - // Check if someone tried to use page-based pagination instead of cursor-based 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 } - - // Check if pagination parameters were explicitly provided _, perPageProvided := args["perPage"] paginationExplicit := perPageProvided - paginationParams, err := pagination.ToGraphQLParams() if err != nil { return nil, nil, err } - - // Use default of 30 if pagination was not explicitly provided if !paginationExplicit { defaultFirst := int32(DefaultGraphQLPageSize) paginationParams.First = &defaultFirst @@ -1502,7 +3026,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } - vars := map[string]interface{}{ + vars := map[string]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "states": states, @@ -1510,29 +3034,23 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { "direction": githubv4.OrderDirection(direction), "first": githubv4.Int(*paginationParams.First), } - if paginationParams.After != nil { vars["after"] = githubv4.String(*paginationParams.After) } else { - // Used within query, therefore must be set to nil and provided as $after vars["after"] = (*githubv4.String)(nil) } - - // Ensure optional parameters are set if hasLabels { - // Use query with labels filtering - convert string labels to githubv4.String slice 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 := getIssueQueryType(hasLabels, hasSince) + issueQuery := getLegacyIssueQueryType(hasLabels, hasSince) if err := client.Query(ctx, issueQuery, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse( ctx, @@ -1541,474 +3059,129 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { ), nil, nil } - // Extract and convert all issue nodes using the common interface - var issues []*github.Issue - var pageInfo struct { - HasNextPage githubv4.Boolean - HasPreviousPage githubv4.Boolean - StartCursor githubv4.String - EndCursor githubv4.String + var resp MinimalIssuesResponse + var isPrivate bool + if queryResult, ok := issueQuery.(LegacyIssueQueryResult); ok { + resp = convertLegacyToMinimalIssuesResponse(queryResult.GetLegacyIssueFragment()) + isPrivate = queryResult.GetIsPrivate() } - var totalCount int - if queryResult, ok := issueQuery.(IssueQueryResult); ok { - fragment := queryResult.GetIssueFragment() - for _, issue := range fragment.Nodes { - issues = append(issues, fragmentToIssue(issue)) - } - pageInfo = fragment.PageInfo - totalCount = fragment.TotalCount - } - - // Create response with issues - response := map[string]interface{}{ - "issues": issues, - "pageInfo": map[string]interface{}{ - "hasNextPage": pageInfo.HasNextPage, - "hasPreviousPage": pageInfo.HasPreviousPage, - "startCursor": string(pageInfo.StartCursor), - "endCursor": string(pageInfo.EndCursor), - }, - "totalCount": totalCount, - } - out, err := json.Marshal(response) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal issues: %w", err) - } - return utils.NewToolResultText(string(out)), nil, nil + result := MarshalledTextResult(resp) + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelListIssues(isPrivate)) + return result, nil, nil }) + st.FeatureFlagDisable = []string{FeatureFlagIssueFields} + return st } -// mvpDescription is an MVP idea for generating tool descriptions from structured data in a shared format. -// It is not intended for widespread usage and is not a complete implementation. -type mvpDescription struct { - summary string - outcomes []string - referenceLinks []string +// rawFieldFilter is the user-supplied {field_name, value} pair before type resolution. +type rawFieldFilter struct { + Name string + Value string } -func (d *mvpDescription) String() string { - var sb strings.Builder - sb.WriteString(d.summary) - if len(d.outcomes) > 0 { - sb.WriteString("\n\n") - sb.WriteString("This tool can help with the following outcomes:\n") - for _, outcome := range d.outcomes { - sb.WriteString(fmt.Sprintf("- %s\n", outcome)) - } +// 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 } - if len(d.referenceLinks) > 0 { - sb.WriteString("\n\n") - sb.WriteString("More information can be found at:\n") - for _, link := range d.referenceLinks { - sb.WriteString(fmt.Sprintf("- %s\n", link)) + 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") } - return sb.String() -} - -// linkedPullRequest represents a PR linked to an issue by Copilot. -type linkedPullRequest struct { - Number int - URL string - Title string - State string - CreatedAt time.Time -} - -// pollConfigKey is a context key for polling configuration. -type pollConfigKey struct{} - -// PollConfig configures the PR polling behavior. -type PollConfig struct { - MaxAttempts int - Delay time.Duration -} - -// ContextWithPollConfig returns a context with polling configuration. -// Use this in tests to reduce or disable polling. -func ContextWithPollConfig(ctx context.Context, config PollConfig) context.Context { - return context.WithValue(ctx, pollConfigKey{}, config) -} - -// getPollConfig returns the polling configuration from context, or defaults. -func getPollConfig(ctx context.Context) PollConfig { - if config, ok := ctx.Value(pollConfigKey{}).(PollConfig); ok { - return config - } - // Default: 9 attempts with 1s delay = 8s max wait - // Based on observed latency in remote server: p50 ~5s, p90 ~7s - return PollConfig{MaxAttempts: 9, Delay: 1 * time.Second} -} - -// findLinkedCopilotPR searches for a PR created by the copilot-swe-agent bot that references the given issue. -// It queries the issue's timeline for CrossReferencedEvent items from PRs authored by copilot-swe-agent. -// The createdAfter parameter filters to only return PRs created after the specified time. -func findLinkedCopilotPR(ctx context.Context, client *githubv4.Client, owner, repo string, issueNumber int, createdAfter time.Time) (*linkedPullRequest, error) { - // Query timeline items looking for CrossReferencedEvent from PRs by copilot-swe-agent - var query struct { - Repository struct { - Issue struct { - TimelineItems struct { - Nodes []struct { - TypeName string `graphql:"__typename"` - CrossReferencedEvent struct { - Source struct { - PullRequest struct { - Number int - URL string - Title string - State string - CreatedAt githubv4.DateTime - Author struct { - Login string - } - } `graphql:"... on PullRequest"` - } - } `graphql:"... on CrossReferencedEvent"` - } - } `graphql:"timelineItems(first: 20, itemTypes: [CROSS_REFERENCED_EVENT])"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - } - - variables := map[string]any{ - "owner": githubv4.String(owner), - "name": githubv4.String(repo), - "number": githubv4.Int(issueNumber), //nolint:gosec // Issue numbers are always small positive integers - } - - if err := client.Query(ctx, &query, variables); err != nil { - return nil, err - } - - // Look for a PR from copilot-swe-agent created after the assignment time - for _, node := range query.Repository.Issue.TimelineItems.Nodes { - if node.TypeName != "CrossReferencedEvent" { - continue + 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()) } - pr := node.CrossReferencedEvent.Source.PullRequest - if pr.Number > 0 && pr.Author.Login == "copilot-swe-agent" { - // Only return PRs created after the assignment time - if pr.CreatedAt.Time.After(createdAfter) { - return &linkedPullRequest{ - Number: pr.Number, - URL: pr.URL, - Title: pr.Title, - State: pr.State, - CreatedAt: pr.CreatedAt.Time, - }, nil - } + 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 nil, nil + return filters, nil } -func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.ServerTool { - description := mvpDescription{ - summary: "Assign Copilot to a specific issue in a GitHub repository.", - outcomes: []string{ - "a Pull Request created with source code changes to resolve the issue", - }, - referenceLinks: []string{ - "https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot", - }, - } - - return NewTool( - ToolsetMetadataIssues, - mcp.Tool{ - Name: "assign_copilot_to_issue", - Description: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String()), - Icons: octicons.Icons("copilot"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_USER_TITLE", "Assign Copilot to issue"), - ReadOnlyHint: false, - IdempotentHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "Repository owner", - }, - "repo": { - Type: "string", - Description: "Repository name", - }, - "issue_number": { - Type: "number", - Description: "Issue number", - }, - "base_ref": { - Type: "string", - Description: "Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch", - }, - "custom_instructions": { - Type: "string", - Description: "Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description", - }, - }, - Required: []string{"owner", "repo", "issue_number"}, - }, - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - var params struct { - Owner string `mapstructure:"owner"` - Repo string `mapstructure:"repo"` - IssueNumber int32 `mapstructure:"issue_number"` - BaseRef string `mapstructure:"base_ref"` - CustomInstructions string `mapstructure:"custom_instructions"` - } - if err := mapstructure.Decode(args, ¶ms); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - client, err := deps.GetGQLClient(ctx) - if err != nil { - return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // Firstly, we try to find the copilot bot in the suggested actors for the repository. - // Although as I write this, we would expect copilot to be at the top of the list, in future, maybe - // it will not be on the first page of responses, thus we will keep paginating until we find it. - type botAssignee struct { - ID githubv4.ID - Login string - TypeName string `graphql:"__typename"` - } - - type suggestedActorsQuery struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot botAssignee `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - } - - variables := map[string]any{ - "owner": githubv4.String(params.Owner), - "name": githubv4.String(params.Repo), - "endCursor": (*githubv4.String)(nil), - } - - var copilotAssignee *botAssignee - for { - var query suggestedActorsQuery - err := client.Query(ctx, &query, variables) - if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get suggested actors", err), nil, nil - } - - // Iterate all the returned nodes looking for the copilot bot, which is supposed to have the - // same name on each host. We need this in order to get the ID for later assignment. - for _, node := range query.Repository.SuggestedActors.Nodes { - if node.Bot.Login == "copilot-swe-agent" { - copilotAssignee = &node.Bot - break - } - } - - if !query.Repository.SuggestedActors.PageInfo.HasNextPage { - break - } - variables["endCursor"] = githubv4.String(query.Repository.SuggestedActors.PageInfo.EndCursor) - } - - // If we didn't find the copilot bot, we can't proceed any further. - if copilotAssignee == nil { - // The e2e tests depend upon this specific message to skip the test. - return utils.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil, nil - } - - // Next, get the issue ID and repository ID - var getIssueQuery struct { - Repository struct { - ID githubv4.ID - Issue struct { - ID githubv4.ID - Assignees struct { - Nodes []struct { - ID githubv4.ID - } - } `graphql:"assignees(first: 100)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - } - - variables = map[string]any{ - "owner": githubv4.String(params.Owner), - "name": githubv4.String(params.Repo), - "number": githubv4.Int(params.IssueNumber), - } - - if err := client.Query(ctx, &getIssueQuery, variables); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue ID", err), nil, nil - } - - // Build the assignee IDs list including copilot - actorIDs := make([]githubv4.ID, len(getIssueQuery.Repository.Issue.Assignees.Nodes)+1) - for i, node := range getIssueQuery.Repository.Issue.Assignees.Nodes { - actorIDs[i] = node.ID - } - actorIDs[len(getIssueQuery.Repository.Issue.Assignees.Nodes)] = copilotAssignee.ID - - // Prepare agent assignment input - emptyString := githubv4.String("") - agentAssignment := &AgentAssignmentInput{ - CustomAgent: &emptyString, - CustomInstructions: &emptyString, - TargetRepositoryID: getIssueQuery.Repository.ID, - } - - // Add base ref if provided - if params.BaseRef != "" { - baseRef := githubv4.String(params.BaseRef) - agentAssignment.BaseRef = &baseRef - } - - // Add custom instructions if provided - if params.CustomInstructions != "" { - customInstructions := githubv4.String(params.CustomInstructions) - agentAssignment.CustomInstructions = &customInstructions - } - - // Execute the updateIssue mutation with the GraphQL-Features header - // This header is required for the agent assignment API which is not GA yet - var updateIssueMutation struct { - UpdateIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - } - } `graphql:"updateIssue(input: $input)"` - } - - // Add the GraphQL-Features header for the agent assignment API - // The header will be read by the HTTP transport if it's configured to do so - ctxWithFeatures := withGraphQLFeatures(ctx, "issues_copilot_assignment_api_support") - - // Capture the time before assignment to filter out older PRs during polling - assignmentTime := time.Now().UTC() - - if err := client.Mutate( - ctxWithFeatures, - &updateIssueMutation, - UpdateIssueInput{ - ID: getIssueQuery.Repository.Issue.ID, - AssigneeIDs: actorIDs, - AgentAssignment: agentAssignment, - }, - nil, - ); err != nil { - return nil, nil, fmt.Errorf("failed to update issue with agent assignment: %w", err) - } - - // Poll for a linked PR created by Copilot after the assignment - pollConfig := getPollConfig(ctx) - - // Get progress token from request for sending progress notifications - progressToken := request.Params.GetProgressToken() - - // Send initial progress notification that assignment succeeded and polling is starting - if progressToken != nil && request.Session != nil && pollConfig.MaxAttempts > 0 { - _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ - ProgressToken: progressToken, - Progress: 0, - Total: float64(pollConfig.MaxAttempts), - Message: "Copilot assigned to issue, waiting for PR creation...", - }) - } - - var linkedPR *linkedPullRequest - for attempt := range pollConfig.MaxAttempts { - if attempt > 0 { - time.Sleep(pollConfig.Delay) - } - - // Send progress notification if progress token is available - if progressToken != nil && request.Session != nil { - _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ - ProgressToken: progressToken, - Progress: float64(attempt + 1), - Total: float64(pollConfig.MaxAttempts), - Message: fmt.Sprintf("Waiting for Copilot to create PR... (attempt %d/%d)", attempt+1, pollConfig.MaxAttempts), - }) - } +// 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, ", ")) + } - pr, err := findLinkedCopilotPR(ctx, client, params.Owner, params.Repo, int(params.IssueNumber), assignmentTime) - if err != nil { - // Polling errors are non-fatal, continue to next attempt - continue - } - if pr != nil { - linkedPR = pr + 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 } } - - // Build the result - result := map[string]any{ - "message": "successfully assigned copilot to issue", - "issue_number": int(updateIssueMutation.UpdateIssue.Issue.Number), - "issue_url": string(updateIssueMutation.UpdateIssue.Issue.URL), - "owner": params.Owner, - "repo": params.Repo, - } - - // Add PR info if found during polling - if linkedPR != nil { - result["pull_request"] = map[string]any{ - "number": linkedPR.Number, - "url": linkedPR.URL, - "title": linkedPR.Title, - "state": linkedPR.State, + if matched == "" { + optionNames := make([]string, 0, len(field.Options)) + for _, o := range field.Options { + optionNames = append(optionNames, o.Name) } - result["message"] = "successfully assigned copilot to issue - pull request created" - } else { - result["message"] = "successfully assigned copilot to issue - pull request pending" - result["note"] = "The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates." - } - - r, err := json.Marshal(result) + 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 utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil + return nil, fmt.Errorf("field_filters: %q is not a valid number for %q: %s", rf.Value, field.Name, err.Error()) } - - return utils.NewToolResultText(string(r)), result, nil - }) -} - -type ReplaceActorsForAssignableInput struct { - AssignableID githubv4.ID `json:"assignableId"` - ActorIDs []githubv4.ID `json:"actorIds"` -} - -// AgentAssignmentInput represents the input for assigning an agent to an issue. -type AgentAssignmentInput struct { - BaseRef *githubv4.String `json:"baseRef,omitempty"` - CustomAgent *githubv4.String `json:"customAgent,omitempty"` - CustomInstructions *githubv4.String `json:"customInstructions,omitempty"` - TargetRepositoryID githubv4.ID `json:"targetRepositoryId"` -} - -// UpdateIssueInput represents the input for updating an issue with agent assignment. -type UpdateIssueInput struct { - ID githubv4.ID `json:"id"` - AssigneeIDs []githubv4.ID `json:"assigneeIds"` - AgentAssignment *AgentAssignmentInput `json:"agentAssignment,omitempty"` + 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. @@ -2034,81 +3207,3 @@ func parseISOTimestamp(timestamp string) (time.Time, error) { // Return error with supported formats return time.Time{}, fmt.Errorf("invalid ISO 8601 timestamp: %s (supported formats: YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DD)", timestamp) } - -func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) inventory.ServerPrompt { - return inventory.NewServerPrompt( - ToolsetMetadataIssues, - mcp.Prompt{ - Name: "AssignCodingAgent", - Description: t("PROMPT_ASSIGN_CODING_AGENT_DESCRIPTION", "Assign GitHub Coding Agent to multiple tasks in a GitHub repository."), - Arguments: []*mcp.PromptArgument{ - { - Name: "repo", - Description: "The repository to assign tasks in (owner/repo).", - Required: true, - }, - }, - }, - func(_ context.Context, request *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - repo := request.Params.Arguments["repo"] - - messages := []*mcp.PromptMessage{ - { - Role: "user", - Content: &mcp.TextContent{ - Text: "You are a personal assistant for GitHub the Copilot GitHub Coding Agent. Your task is to help the user assign tasks to the Coding Agent based on their open GitHub issues. You can use `assign_copilot_to_issue` tool to assign the Coding Agent to issues that are suitable for autonomous work, and `search_issues` tool to find issues that match the user's criteria. You can also use `list_issues` to get a list of issues in the repository.", - }, - }, - { - Role: "user", - Content: &mcp.TextContent{ - Text: fmt.Sprintf("Please go and get a list of the most recent 10 issues from the %s GitHub repository", repo), - }, - }, - { - Role: "assistant", - Content: &mcp.TextContent{ - Text: fmt.Sprintf("Sure! I will get a list of the 10 most recent issues for the repo %s.", repo), - }, - }, - { - Role: "user", - Content: &mcp.TextContent{ - Text: "For each issue, please check if it is a clearly defined coding task with acceptance criteria and a low to medium complexity to identify issues that are suitable for an AI Coding Agent to work on. Then assign each of the identified issues to Copilot.", - }, - }, - { - Role: "assistant", - Content: &mcp.TextContent{ - Text: "Certainly! Let me carefully check which ones are clearly scoped issues that are good to assign to the coding agent, and I will summarize and assign them now.", - }, - }, - { - Role: "user", - Content: &mcp.TextContent{ - Text: "Great, if you are unsure if an issue is good to assign, ask me first, rather than assigning copilot. If you are certain the issue is clear and suitable you can assign it to Copilot without asking.", - }, - }, - } - return &mcp.GetPromptResult{ - Messages: messages, - }, nil - }, - ) -} - -// graphQLFeaturesKey is a context key for GraphQL feature flags -type graphQLFeaturesKey struct{} - -// withGraphQLFeatures adds GraphQL feature flags to the context -func withGraphQLFeatures(ctx context.Context, features ...string) context.Context { - return context.WithValue(ctx, graphQLFeaturesKey{}, features) -} - -// GetGraphQLFeatures retrieves GraphQL feature flags from the context -func GetGraphQLFeatures(ctx context.Context) []string { - if features, ok := ctx.Value(graphQLFeaturesKey{}).([]string); ok { - return features - } - return nil -} diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go new file mode 100644 index 0000000000..157d5595fd --- /dev/null +++ b/pkg/github/issues_granular.go @@ -0,0 +1,1200 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "strings" + + 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/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +func normalizeConfidence(confidence string) string { + return strings.ToUpper(strings.TrimSpace(confidence)) +} + +// 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 + } + confidence = normalizeConfidence(confidence) + 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 + } + confidence = normalizeConfidence(confidence) + 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."), + 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 + } + confidence = normalizeConfidence(confidence) + 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, + } + + // The rationale and suggest input fields on IssueFieldCreateOrUpdateInput + // are gated behind the update_issue_suggestions GraphQL feature flag. + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "update_issue_suggestions") + if err := gqlClient.Mutate(ctxWithFeatures, &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 a338efcbab..5775daf377 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -13,9 +13,11 @@ 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/inventory" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v79/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 +25,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 +41,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 +65,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,20 +157,20 @@ func Test_GetIssue(t *testing.T) { tests := []struct { name string mockedClient *http.Client - gqlHTTPClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectHandlerError bool expectResultError bool expectedIssue *github.Issue expectedErrMsg string lockdownEnabled bool + restPermission string }{ { name: "successful issue retrieval", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get", "owner": "owner2", "repo": "repo2", @@ -196,7 +183,7 @@ func Test_GetIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get", "owner": "owner", "repo": "repo", @@ -210,37 +197,7 @@ 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]interface{}{ + requestArgs: map[string]any{ "method": "get", "owner": "owner2", "repo": "repo2", @@ -248,50 +205,14 @@ 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]interface{}{ + requestArgs: map[string]any{ "method": "get", "owner": "owner", "repo": "repo", @@ -300,26 +221,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, } @@ -345,19 +264,302 @@ func Test_GetIssue(t *testing.T) { textContent := getTextResult(t, result) - var returnedIssue github.Issue + var returnedIssue MinimalIssue err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) require.NoError(t, err) - assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) - assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) - assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) - assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) - assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) - assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) + assert.Equal(t, tc.expectedIssue.GetNumber(), returnedIssue.Number) + assert.Equal(t, tc.expectedIssue.GetTitle(), returnedIssue.Title) + assert.Equal(t, tc.expectedIssue.GetBody(), returnedIssue.Body) + assert.Equal(t, tc.expectedIssue.GetState(), returnedIssue.State) + assert.Equal(t, tc.expectedIssue.GetHTMLURL(), returnedIssue.HTMLURL) + assert.Equal(t, tc.expectedIssue.GetUser().GetLogin(), returnedIssue.User.Login) }) } } +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 trusted", 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, "trusted", 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) @@ -386,7 +588,7 @@ func Test_AddIssueComment(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedComment *github.IssueComment expectedErrMsg string @@ -396,7 +598,7 @@ func Test_AddIssueComment(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockComment), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -413,7 +615,7 @@ func Test_AddIssueComment(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "issue_number": float64(42), @@ -427,7 +629,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, } @@ -458,14 +660,12 @@ func Test_AddIssueComment(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedComment github.IssueComment - err = json.Unmarshal([]byte(textContent.Text), &returnedComment) + // Unmarshal and verify the result contains minimal response + var minimalResponse MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &minimalResponse) require.NoError(t, err) - assert.Equal(t, *tc.expectedComment.ID, *returnedComment.ID) - assert.Equal(t, *tc.expectedComment.Body, *returnedComment.Body) - assert.Equal(t, *tc.expectedComment.User.Login, *returnedComment.User.Login) - + assert.Equal(t, fmt.Sprintf("%d", tc.expectedComment.GetID()), minimalResponse.ID) + assert.Equal(t, tc.expectedComment.GetHTMLURL(), minimalResponse.URL) }) } } @@ -520,7 +720,7 @@ func Test_SearchIssues(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedResult *github.IssuesSearchResult expectedErrMsg string @@ -541,7 +741,7 @@ func Test_SearchIssues(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "repo:owner/repo is:open", "sort": "created", "order": "desc", @@ -567,7 +767,7 @@ func Test_SearchIssues(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "is:open", "owner": "test-owner", "repo": "test-repo", @@ -591,7 +791,7 @@ func Test_SearchIssues(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "bug", "owner": "test-owner", }, @@ -612,7 +812,7 @@ func Test_SearchIssues(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "feature", "repo": "test-repo", }, @@ -624,7 +824,7 @@ func Test_SearchIssues(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "is:issue repo:owner/repo is:open", }, expectError: false, @@ -644,7 +844,7 @@ func Test_SearchIssues(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", }, expectError: false, @@ -664,7 +864,7 @@ func Test_SearchIssues(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "repo:github/github-mcp-server critical", "owner": "different-owner", "repo": "different-repo", @@ -686,7 +886,7 @@ func Test_SearchIssues(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "is:issue repo:octocat/Hello-World bug", }, expectError: false, @@ -706,12 +906,53 @@ func Test_SearchIssues(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", }, 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{ @@ -720,7 +961,7 @@ func Test_SearchIssues(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "invalid:query", }, expectError: true, @@ -731,7 +972,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, } @@ -777,71 +1018,334 @@ 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]interface{} - 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]interface{}{ - "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) + }, + } + } + + 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, }, @@ -855,7 +1359,7 @@ func Test_CreateIssue(t *testing.T) { State: github.Ptr("open"), }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "create", "owner": "owner", "repo": "repo", @@ -870,6 +1374,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{ @@ -878,7 +1453,7 @@ func Test_CreateIssue(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Validation failed"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "create", "owner": "owner", "repo": "repo", @@ -887,13 +1462,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, @@ -933,67 +1527,404 @@ func Test_CreateIssue(t *testing.T) { } } -func Test_ListIssues(t *testing.T) { - // Verify tool definition - serverTool := ListIssues(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) +// 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_MCPAppsFeature_UIGate(t *testing.T) { + t.Parallel() - assert.Equal(t, "list_issues", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "labels") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "orderBy") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "direction") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "since") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "after") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo"}) + mockIssue := &github.Issue{ + Number: github.Ptr(1), + Title: github.Ptr("Test"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/1"), + } - // 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"}, - "labels": map[string]any{ - "nodes": []map[string]any{ - {"name": "bug", "id": "label1", "description": "Bug label"}, - }, - }, - "comments": map[string]any{ - "totalCount": 5, - }, - }, - { - "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"}, - "labels": map[string]any{ - "nodes": []map[string]any{ - {"name": "enhancement", "id": "label2", "description": "Enhancement label"}, - }, - }, - "comments": map[string]any{ - "totalCount": 3, - }, - }, + serverTool := IssueWrite(translations.NullTranslationHelper) + + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: mockResponse(t, http.StatusCreated, mockIssue), + })) + + deps := BaseDeps{ + Client: client, + GQLClient: githubv4.NewClient(nil), + featureChecker: featureCheckerFor(MCPAppsFeatureFlag), } + handler := serverTool.Handler(deps) + + t.Run("UI client without _ui_submitted returns form message", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) - mockIssuesOpen := []map[string]any{mockIssuesAll[0], mockIssuesAll[1]} - mockIssuesClosed := []map[string]any{ + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new issue") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") + }) + + t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + "_ui_submitted": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", + "tool should return the created issue URL") + }) + + t.Run("non-UI client executes directly without _ui_submitted", func(t *testing.T) { + request := createMCPRequest(map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", + "non-UI client should execute directly") + }) + + t.Run("UI client with state change routes through UI form", func(t *testing.T) { + // state/state_reason/duplicate_of are form params (the issue-write view + // renders close/reopen controls), so a call carrying them must go to + // the form rather than execute directly. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "state": "closed", + "state_reason": "completed", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for editing issue #1", + "state change should route through UI form") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") + }) + + t.Run("UI client update without state change returns form message", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "title": "New Title", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for editing issue #1", + "update without state should show UI form") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") + }) + + t.Run("UI client with issue_fields routes through UI form", func(t *testing.T) { + // issue_fields is now a form param (the issue-write view renders a + // per-field editor), so a call carrying it must go to the form rather + // than execute directly. + 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 := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new issue", + "issue_fields should route through UI form") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") + }) + + 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, "interactive form has been shown", + "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") + }) + + t.Run("UI client with show_ui=false skips form and executes directly", func(t *testing.T) { + // show_ui=false is the explicit, model-facing way to opt out of the + // form. It must bypass the form even when every other condition would + // route the call there (UI capability, MCP Apps flag on, no + // _ui_submitted, only form params present). + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + "show_ui": false, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "interactive form has been shown", + "show_ui=false should skip UI form") + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", + "show_ui=false call should execute directly and return issue URL") + }) + + t.Run("UI client with show_ui=true returns form message", func(t *testing.T) { + // show_ui=true is the explicit, redundant-with-the-default way to ask + // for the form. It must still route through the form and must not be + // treated as a non-form parameter that would trigger the safety-net + // bypass. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + "show_ui": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "interactive form has been shown", + "show_ui=true should still route through the form") + }) + + t.Run("UI client with show_ui=false and _ui_submitted=true executes directly", func(t *testing.T) { + // _ui_submitted and show_ui=false are two ways to say "execute + // directly". When both are set there must be no conflict — the call + // still executes directly. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + "show_ui": false, + "_ui_submitted": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", + "show_ui=false + _ui_submitted should execute directly") + }) + + t.Run("non-UI client with show_ui=false executes directly (no regression)", func(t *testing.T) { + // show_ui is irrelevant when the client does not support UI; the call + // must execute directly exactly as it does today. + request := createMCPRequest(map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + "show_ui": false, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", + "non-UI client should execute directly regardless of show_ui") + }) +} + +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: "show_ui true is a form param", args: map[string]any{"title": "t", "show_ui": true}, want: false}, + {name: "show_ui false is a form param", args: map[string]any{"title": "t", "show_ui": false}, 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: false}, + {name: "state present", args: map[string]any{"state": "closed"}, want: false}, + {name: "state_reason present", args: map[string]any{"state_reason": "completed"}, want: false}, + {name: "duplicate_of present", args: map[string]any{"duplicate_of": float64(7)}, want: false}, + {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)) + }) + } +} + +// Test_issueWriteSchemaClassification fails when a schema property is added +// without classifying it as either form-resendable (issueWriteFormParams) or +// known-non-form (knownNonForm below). Without this guard, an unclassified +// property would silently flip UI gating: form-incompatible fields would +// stop tripping the safety-net bypass and the form would drop their values. +func Test_issueWriteSchemaClassification(t *testing.T) { + t.Parallel() + + // Schema properties the MCP App form cannot represent — their presence + // must trigger the safety-net bypass via issueWriteHasNonFormParams. + knownNonForm := map[string]struct{}{ + "assignees": {}, + "labels": {}, + "milestone": {}, + "type": {}, + } + + cases := []struct { + name string + tool inventory.ServerTool + }{ + {name: "IssueWrite", tool: IssueWrite(translations.NullTranslationHelper)}, + {name: "LegacyIssueWrite", tool: LegacyIssueWrite(translations.NullTranslationHelper)}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + schema, ok := tc.tool.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + for prop := range schema.Properties { + _, isForm := issueWriteFormParams[prop] + _, isNonForm := knownNonForm[prop] + + assert.Falsef(t, isForm && isNonForm, + "property %q is classified as both form-resendable and non-form — pick one", prop) + assert.Truef(t, isForm || isNonForm, + "property %q in %s schema is unclassified — add it to issueWriteFormParams (pkg/github/issues.go) "+ + "if the MCP App form can carry it on submit, otherwise add it to the knownNonForm allowlist in this test", + prop, tc.name) + } + }) + } +} + +func Test_ListIssues(t *testing.T) { + // Verify tool definition + serverTool := ListIssues(translations.NullTranslationHelper) + tool := serverTool.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) + 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, "state") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "labels") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "orderBy") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "direction") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "since") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "after") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo"}) + + // 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"}, + "labels": map[string]any{ + "nodes": []map[string]any{ + {"name": "bug", "id": "label1", "description": "Bug label"}, + }, + }, + "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"}, + "labels": map[string]any{ + "nodes": []map[string]any{ + {"name": "enhancement", "id": "label2", "description": "Enhancement label"}, + }, + }, + "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", @@ -1009,6 +1940,9 @@ func Test_ListIssues(t *testing.T) { "comments": map[string]any{ "totalCount": 1, }, + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{}, + }, }, } @@ -1025,6 +1959,7 @@ func Test_ListIssues(t *testing.T) { }, "totalCount": 2, }, + "isPrivate": false, }, }) @@ -1040,6 +1975,7 @@ func Test_ListIssues(t *testing.T) { }, "totalCount": 2, }, + "isPrivate": false, }, }) @@ -1055,74 +1991,81 @@ 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 - varsListAll := map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "states": []interface{}{"OPEN", "CLOSED"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), + // 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), + "issueFieldValues": []any{}, } - varsOpenOnly := map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "states": []interface{}{"OPEN"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), + varsOpenOnly := map[string]any{ + "owner": "owner", + "repo": "repo", + "states": []any{"OPEN"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + "issueFieldValues": []any{}, } - varsClosedOnly := map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "states": []interface{}{"CLOSED"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), + varsClosedOnly := map[string]any{ + "owner": "owner", + "repo": "repo", + "states": []any{"CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + "issueFieldValues": []any{}, } - varsWithLabels := map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "states": []interface{}{"OPEN", "CLOSED"}, - "labels": []interface{}{"bug", "enhancement"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), + 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), + "issueFieldValues": []any{}, } - varsRepoNotFound := map[string]interface{}{ - "owner": "owner", - "repo": "nonexistent-repo", - "states": []interface{}{"OPEN", "CLOSED"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), + varsRepoNotFound := map[string]any{ + "owner": "owner", + "repo": "nonexistent-repo", + "states": []any{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + "issueFieldValues": []any{}, } tests := []struct { name string - reqParams map[string]interface{} + reqParams map[string]any expectError bool errContains string expectedCount int - verifyOrder func(t *testing.T, issues []*github.Issue) }{ { name: "list all issues", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -1131,7 +2074,7 @@ func Test_ListIssues(t *testing.T) { }, { name: "filter by open state", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", "repo": "repo", "state": "OPEN", @@ -1141,7 +2084,7 @@ func Test_ListIssues(t *testing.T) { }, { name: "filter by open state - lc", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", "repo": "repo", "state": "open", @@ -1151,7 +2094,7 @@ func Test_ListIssues(t *testing.T) { }, { name: "filter by closed state", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", "repo": "repo", "state": "CLOSED", @@ -1161,7 +2104,7 @@ func Test_ListIssues(t *testing.T) { }, { name: "filter by labels", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", "repo": "repo", "labels": []any{"bug", "enhancement"}, @@ -1171,7 +2114,7 @@ func Test_ListIssues(t *testing.T) { }, { name: "repository not found error", - reqParams: map[string]interface{}{ + reqParams: map[string]any{ "owner": "owner", "repo": "nonexistent-repo", }, @@ -1181,8 +2124,9 @@ func Test_ListIssues(t *testing.T) { } // 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}}}" + 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,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,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) { @@ -1227,114 +2171,778 @@ func Test_ListIssues(t *testing.T) { require.NoError(t, err) // Parse the structured response with pagination info - var response struct { - Issues []*github.Issue `json:"issues"` - PageInfo struct { - HasNextPage bool `json:"hasNextPage"` - HasPreviousPage bool `json:"hasPreviousPage"` - StartCursor string `json:"startCursor"` - EndCursor string `json:"endCursor"` - } `json:"pageInfo"` - TotalCount int `json:"totalCount"` - } + 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 order if verifyOrder function is provided - if tc.verifyOrder != nil { - tc.verifyOrder(t, 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.NotNil(t, issue.Number, "Issue should have number") - assert.NotNil(t, issue.Title, "Issue should have title") - assert.NotNil(t, issue.State, "Issue should have state") + 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, []MinimalFieldValue{{Field: "priority", Value: "P1"}}, issue.FieldValues) + case 456: + assert.Equal(t, []MinimalFieldValue{ + {Field: "due", Value: "2026-06-01"}, + {Field: "estimate", Value: "2.5"}, + {Field: "notes", Value: "needs triage"}, + }, issue.FieldValues) + default: + assert.Empty(t, issue.FieldValues) + } } }) } } -func Test_UpdateIssue(t *testing.T) { - // Verify tool definition - serverTool := IssueWrite(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - 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, "issue_number") - 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, "labels") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "assignees") - 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, "state") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state_reason") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "duplicate_of") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo"}) +func Test_ListIssues_FieldFilters(t *testing.T) { + t.Parallel() - // Mock issues for reuse across test cases - mockBaseIssue := &github.Issue{ - Number: github.Ptr(123), - Title: github.Ptr("Title"), - Body: github.Ptr("Description"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), - Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, - Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, - Milestone: &github.Milestone{Number: github.Ptr(5)}, - Type: &github.IssueType{Name: github.Ptr("Bug")}, - } + serverTool := ListIssues(translations.NullTranslationHelper) - mockUpdatedIssue := &github.Issue{ - Number: github.Ptr(123), - Title: github.Ptr("Updated Title"), - Body: github.Ptr("Updated Description"), - State: github.Ptr("closed"), - StateReason: github.Ptr("duplicate"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), - Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, - Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, - Milestone: &github.Milestone{Number: github.Ptr(5)}, - Type: &github.IssueType{Name: github.Ptr("Bug")}, + 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}, + }, } - mockReopenedIssue := &github.Issue{ - Number: github.Ptr(123), - Title: github.Ptr("Title"), - State: github.Ptr("open"), - StateReason: github.Ptr("reopened"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + pageInfo := map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", } - // Mock GraphQL responses for reuse across test cases - issueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ + response := githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ - "issue": map[string]any{ - "id": "I_kwDOA0xdyM50BPaO", + "issues": map[string]any{ + "nodes": mockIssues, + "pageInfo": pageInfo, + "totalCount": 1, }, + "isPrivate": false, }, }) - duplicateIssueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ + // 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{ - "issue": map[string]any{ - "id": "I_kwDOA0xdyM50BPaO", - }, - "duplicateIssue": map[string]any{ - "id": "I_kwDOA0xdyM50BPbP", + "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, + ) + } - closeSuccessResponse := githubv4mock.DataResponse(map[string]any{ + 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,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,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{ + { + "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}, + }, + } + + 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, + }, + }) + } + + 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,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{}, + } + + reqParams := map[string]any{"owner": "octocat", "repo": "hello"} + + 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) + + 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) { + matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(false)) + 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, "public", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode enabled on private repo emits private trusted 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, "trusted", 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, + "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,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.Nil(t, resp.Issues[0].FieldValues, "legacy list_issues must not return field_values") +} + +func Test_UpdateIssue(t *testing.T) { + // Verify tool definition (flag-enabled variant snap) + serverTool := IssueWrite(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueFields, tool)) + + 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, "issue_number") + 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, "labels") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "assignees") + 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, "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 + mockBaseIssue := &github.Issue{ + Number: github.Ptr(123), + Title: github.Ptr("Title"), + Body: github.Ptr("Description"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, + Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, + Milestone: &github.Milestone{Number: github.Ptr(5)}, + Type: &github.IssueType{Name: github.Ptr("Bug")}, + } + + mockUpdatedIssue := &github.Issue{ + Number: github.Ptr(123), + Title: github.Ptr("Updated Title"), + Body: github.Ptr("Updated Description"), + State: github.Ptr("closed"), + StateReason: github.Ptr("duplicate"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, + Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, + Milestone: &github.Milestone{Number: github.Ptr(5)}, + Type: &github.IssueType{Name: github.Ptr("Bug")}, + } + + mockReopenedIssue := &github.Issue{ + Number: github.Ptr(123), + Title: github.Ptr("Title"), + State: github.Ptr("open"), + StateReason: github.Ptr("reopened"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + } + + // Mock GraphQL responses for reuse across test cases + issueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "id": "I_kwDOA0xdyM50BPaO", + }, + }, + }) + + duplicateIssueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "id": "I_kwDOA0xdyM50BPaO", + }, + "duplicateIssue": map[string]any{ + "id": "I_kwDOA0xdyM50BPbP", + }, + }, + }) + + closeSuccessResponse := githubv4mock.DataResponse(map[string]any{ "closeIssue": map[string]any{ "issue": map[string]any{ "id": "I_kwDOA0xdyM50BPaO", @@ -1362,7 +2970,7 @@ func Test_UpdateIssue(t *testing.T) { name string mockedRESTClient *http.Client mockedGQLClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedIssue *github.Issue expectedErrMsg string @@ -1370,7 +2978,7 @@ func Test_UpdateIssue(t *testing.T) { { name: "partial update of non-state fields only", mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]interface{}{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ "title": "Updated Title", "body": "Updated Description", }).andThen( @@ -1378,7 +2986,7 @@ func Test_UpdateIssue(t *testing.T) { ), }), mockedGQLClient: githubv4mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "update", "owner": "owner", "repo": "repo", @@ -1390,35 +2998,137 @@ func Test_UpdateIssue(t *testing.T) { expectedIssue: mockUpdatedIssue, }, { - name: "issue not found when updating non-state fields only", + name: "partial update clears labels and assignees", mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchReposIssuesByOwnerByRepoByIssueNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), + 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]interface{}{ + requestArgs: map[string]any{ "method": "update", "owner": "owner", "repo": "repo", - "issue_number": float64(999), - "title": "Updated Title", + "issue_number": float64(123), + "labels": []any{}, + "assignees": []any{}, + }, + expectError: false, + expectedIssue: &github.Issue{ + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), }, - expectError: true, - expectedErrMsg: "failed to update issue", }, { - name: "close issue as duplicate", + name: "partial update with issue fields reconciled by names", mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), + 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( - struct { - Repository struct { - Issue struct { - ID githubv4.ID + "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{ + PatchReposIssuesByOwnerByRepoByIssueNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), + mockedGQLClient: githubv4mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + "title": "Updated Title", + }, + expectError: true, + expectedErrMsg: "failed to update issue", + }, + { + name: "close issue as duplicate", + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), + }), + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID } `graphql:"issue(number: $issueNumber)"` DuplicateIssue struct { ID githubv4.ID @@ -1453,7 +3163,7 @@ func Test_UpdateIssue(t *testing.T) { closeSuccessResponse, ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "update", "owner": "owner", "repo": "repo", @@ -1504,7 +3214,7 @@ func Test_UpdateIssue(t *testing.T) { reopenSuccessResponse, ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "update", "owner": "owner", "repo": "repo", @@ -1536,7 +3246,7 @@ func Test_UpdateIssue(t *testing.T) { githubv4mock.ErrorResponse("Could not resolve to an Issue with the number of 999."), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "update", "owner": "owner", "repo": "repo", @@ -1573,7 +3283,7 @@ func Test_UpdateIssue(t *testing.T) { githubv4mock.ErrorResponse("Could not resolve to an Issue with the number of 999."), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "update", "owner": "owner", "repo": "repo", @@ -1588,7 +3298,7 @@ func Test_UpdateIssue(t *testing.T) { { name: "close as duplicate with combined non-state updates", mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]interface{}{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ "title": "Updated Title", "body": "Updated Description", "labels": []any{"bug", "priority"}, @@ -1649,7 +3359,7 @@ func Test_UpdateIssue(t *testing.T) { closeSuccessResponse, ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "update", "owner": "owner", "repo": "repo", @@ -1671,7 +3381,7 @@ func Test_UpdateIssue(t *testing.T) { name: "duplicate_of without duplicate state_reason should fail", mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), mockedGQLClient: githubv4mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "update", "owner": "owner", "repo": "repo", @@ -1688,7 +3398,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, @@ -1733,6 +3443,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 @@ -1822,8 +3573,7 @@ func Test_GetIssueComments(t *testing.T) { tests := []struct { name string mockedClient *http.Client - gqlHTTPClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedComments []*github.IssueComment expectedErrMsg string @@ -1834,7 +3584,7 @@ func Test_GetIssueComments(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockComments), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_comments", "owner": "owner", "repo": "repo", @@ -1853,7 +3603,7 @@ func Test_GetIssueComments(t *testing.T) { mockResponse(t, http.StatusOK, mockComments), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_comments", "owner": "owner", "repo": "repo", @@ -1869,7 +3619,7 @@ func Test_GetIssueComments(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_comments", "owner": "owner", "repo": "repo", @@ -1894,8 +3644,7 @@ func Test_GetIssueComments(t *testing.T) { }, }), }), - gqlHTTPClient: newRepoAccessHTTPClient(), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_comments", "owner": "owner", "repo": "repo", @@ -1916,882 +3665,159 @@ 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, - RepoAccessCache: cache, - Flags: flags, - } - handler := serverTool.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - // Verify results - if tc.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.expectedErrMsg) - return - } - - require.NoError(t, err) - textContent := getTextResult(t, result) - - // Unmarshal and verify the result - var returnedComments []*github.IssueComment - err = json.Unmarshal([]byte(textContent.Text), &returnedComments) - require.NoError(t, err) - assert.Equal(t, len(tc.expectedComments), len(returnedComments)) - for i := range tc.expectedComments { - require.NotNil(t, tc.expectedComments[i].User) - require.NotNil(t, returnedComments[i].User) - assert.Equal(t, tc.expectedComments[i].GetID(), returnedComments[i].GetID()) - assert.Equal(t, tc.expectedComments[i].GetBody(), returnedComments[i].GetBody()) - assert.Equal(t, tc.expectedComments[i].GetUser().GetLogin(), returnedComments[i].GetUser().GetLogin()) - } - }) - } -} - -func Test_GetIssueLabels(t *testing.T) { - t.Parallel() - - // Verify tool definition - serverTool := IssueRead(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "issue_read", 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, "issue_number") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number"}) - - tests := []struct { - name string - requestArgs map[string]any - mockedClient *http.Client - expectToolError bool - expectedToolErrMsg string - }{ - { - name: "successful issue labels listing", - requestArgs: map[string]any{ - "method": "get_labels", - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Issue struct { - Labels struct { - Nodes []struct { - ID githubv4.ID - Name githubv4.String - Color githubv4.String - Description githubv4.String - } - TotalCount githubv4.Int - } `graphql:"labels(first: 100)"` - } `graphql:"issue(number: $issueNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "issueNumber": githubv4.Int(123), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issue": map[string]any{ - "labels": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("label-1"), - "name": githubv4.String("bug"), - "color": githubv4.String("d73a4a"), - "description": githubv4.String("Something isn't working"), - }, - }, - "totalCount": githubv4.Int(1), - }, - }, - }, - }), - ), - ), - expectToolError: false, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - gqlClient := githubv4.NewClient(tc.mockedClient) - client := github.NewClient(nil) - deps := BaseDeps{ - Client: client, - GQLClient: gqlClient, - RepoAccessCache: stubRepoAccessCache(gqlClient, 15*time.Minute), - Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), - } - handler := serverTool.Handler(deps) - - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - assert.NotNil(t, result) - - if tc.expectToolError { - assert.True(t, result.IsError) - if tc.expectedToolErrMsg != "" { - textContent := getErrorResult(t, result) - assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) - } - } else { - assert.False(t, result.IsError) - } - }) - } -} - -func TestAssignCopilotToIssue(t *testing.T) { - t.Parallel() - - // Verify tool definition - serverTool := AssignCopilotToIssue(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "assign_copilot_to_issue", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "base_ref") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "custom_instructions") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issue_number"}) - - // Helper function to create pointer to githubv4.String - ptrGitHubv4String := func(s string) *githubv4.String { - v := githubv4.String(s) - return &v - } - - var pageOfFakeBots = func(n int) []struct{} { - // We don't _really_ need real bots here, just objects that count as entries for the page - bots := make([]struct{}, n) - for i := range n { - bots[i] = struct{}{} - } - return bots - } - - tests := []struct { - name string - requestArgs map[string]any - mockedClient *http.Client - expectToolError bool - expectedToolErrMsg string - }{ - { - name: "successful assignment when there are no existing assignees", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("copilot-swe-agent-id"), - "login": githubv4.String("copilot-swe-agent"), - "__typename": "Bot", - }, - }, - }, - }, - }), - ), - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - ID githubv4.ID - Issue struct { - ID githubv4.ID - Assignees struct { - Nodes []struct { - ID githubv4.ID - } - } `graphql:"assignees(first: 100)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "number": githubv4.Int(123), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "id": githubv4.ID("test-repo-id"), - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "assignees": map[string]any{ - "nodes": []any{}, - }, - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - UpdateIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - } - } `graphql:"updateIssue(input: $input)"` - }{}, - UpdateIssueInput{ - ID: githubv4.ID("test-issue-id"), - AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, - AgentAssignment: &AgentAssignmentInput{ - BaseRef: nil, - CustomAgent: ptrGitHubv4String(""), - CustomInstructions: ptrGitHubv4String(""), - TargetRepositoryID: githubv4.ID("test-repo-id"), - }, - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "updateIssue": map[string]any{ - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "number": githubv4.Int(123), - "url": githubv4.String("https://github.com/owner/repo/issues/123"), - }, - }, - }), - ), - ), - }, - { - name: "successful assignment when there are existing assignees", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("copilot-swe-agent-id"), - "login": githubv4.String("copilot-swe-agent"), - "__typename": "Bot", - }, - }, - }, - }, - }), - ), - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - ID githubv4.ID - Issue struct { - ID githubv4.ID - Assignees struct { - Nodes []struct { - ID githubv4.ID - } - } `graphql:"assignees(first: 100)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "number": githubv4.Int(123), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "id": githubv4.ID("test-repo-id"), - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "assignees": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("existing-assignee-id"), - }, - map[string]any{ - "id": githubv4.ID("existing-assignee-id-2"), - }, - }, - }, - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - UpdateIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - } - } `graphql:"updateIssue(input: $input)"` - }{}, - UpdateIssueInput{ - ID: githubv4.ID("test-issue-id"), - AssigneeIDs: []githubv4.ID{ - githubv4.ID("existing-assignee-id"), - githubv4.ID("existing-assignee-id-2"), - githubv4.ID("copilot-swe-agent-id"), - }, - AgentAssignment: &AgentAssignmentInput{ - BaseRef: nil, - CustomAgent: ptrGitHubv4String(""), - CustomInstructions: ptrGitHubv4String(""), - TargetRepositoryID: githubv4.ID("test-repo-id"), - }, - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "updateIssue": map[string]any{ - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "number": githubv4.Int(123), - "url": githubv4.String("https://github.com/owner/repo/issues/123"), - }, - }, - }), - ), - ), - }, - { - name: "copilot bot not on first page of suggested actors", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - // First page of suggested actors - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": pageOfFakeBots(100), - "pageInfo": map[string]any{ - "hasNextPage": true, - "endCursor": githubv4.String("next-page-cursor"), - }, - }, - }, - }), - ), - // Second page of suggested actors - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": githubv4.String("next-page-cursor"), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("copilot-swe-agent-id"), - "login": githubv4.String("copilot-swe-agent"), - "__typename": "Bot", - }, - }, - }, - }, - }), - ), - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - ID githubv4.ID - Issue struct { - ID githubv4.ID - Assignees struct { - Nodes []struct { - ID githubv4.ID - } - } `graphql:"assignees(first: 100)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "number": githubv4.Int(123), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "id": githubv4.ID("test-repo-id"), - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "assignees": map[string]any{ - "nodes": []any{}, - }, - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - UpdateIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - } - } `graphql:"updateIssue(input: $input)"` - }{}, - UpdateIssueInput{ - ID: githubv4.ID("test-issue-id"), - AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, - AgentAssignment: &AgentAssignmentInput{ - BaseRef: nil, - CustomAgent: ptrGitHubv4String(""), - CustomInstructions: ptrGitHubv4String(""), - TargetRepositoryID: githubv4.ID("test-repo-id"), - }, - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "updateIssue": map[string]any{ - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "number": githubv4.Int(123), - "url": githubv4.String("https://github.com/owner/repo/issues/123"), - }, - }, - }), - ), - ), - }, - { - name: "copilot not a suggested actor", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": []any{}, - }, - }, - }), - ), - ), - expectToolError: true, - expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.", - }, + Client: client, + GQLClient: defaultGQLClient, + RepoAccessCache: cache, + Flags: flags, + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedComments []MinimalIssueComment + err = json.Unmarshal([]byte(textContent.Text), &returnedComments) + require.NoError(t, err) + assert.Equal(t, len(tc.expectedComments), len(returnedComments)) + for i := range tc.expectedComments { + require.NotNil(t, tc.expectedComments[i].User) + require.NotNil(t, returnedComments[i].User) + assert.Equal(t, tc.expectedComments[i].GetID(), returnedComments[i].ID) + assert.Equal(t, tc.expectedComments[i].GetBody(), returnedComments[i].Body) + assert.Equal(t, tc.expectedComments[i].GetUser().GetLogin(), returnedComments[i].User.Login) + } + }) + } +} + +func Test_GetIssueLabels(t *testing.T) { + t.Parallel() + + // Verify tool definition + serverTool := IssueRead(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "issue_read", 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, "issue_number") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number"}) + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + }{ { - name: "successful assignment with base_ref specified", + name: "successful issue labels listing", requestArgs: map[string]any{ + "method": "get_labels", "owner": "owner", "repo": "repo", "issue_number": float64(123), - "base_ref": "feature-branch", }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("copilot-swe-agent-id"), - "login": githubv4.String("copilot-swe-agent"), - "__typename": "Bot", - }, - }, - }, - }, - }), - ), - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - ID githubv4.ID Issue struct { - ID githubv4.ID - Assignees struct { + Labels struct { Nodes []struct { - ID githubv4.ID + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String } - } `graphql:"assignees(first: 100)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` + TotalCount githubv4.Int + } `graphql:"labels(first: 100)"` + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` }{}, map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "number": githubv4.Int(123), + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(123), }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ - "id": githubv4.ID("test-repo-id"), - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "assignees": map[string]any{ - "nodes": []any{}, - }, - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - UpdateIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - } - } `graphql:"updateIssue(input: $input)"` - }{}, - UpdateIssueInput{ - ID: githubv4.ID("test-issue-id"), - AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, - AgentAssignment: &AgentAssignmentInput{ - BaseRef: ptrGitHubv4String("feature-branch"), - CustomAgent: ptrGitHubv4String(""), - CustomInstructions: ptrGitHubv4String(""), - TargetRepositoryID: githubv4.ID("test-repo-id"), - }, - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "updateIssue": map[string]any{ "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "number": githubv4.Int(123), - "url": githubv4.String("https://github.com/owner/repo/issues/123"), - }, - }, - }), - ), - ), - }, - { - name: "successful assignment with custom_instructions specified", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - "custom_instructions": "Please ensure all code follows PEP 8 style guidelines and includes comprehensive docstrings", - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("copilot-swe-agent-id"), - "login": githubv4.String("copilot-swe-agent"), - "__typename": "Bot", + "labels": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("label-1"), + "name": githubv4.String("bug"), + "color": githubv4.String("d73a4a"), + "description": githubv4.String("Something isn't working"), + }, }, + "totalCount": githubv4.Int(1), }, }, }, }), ), - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - ID githubv4.ID - Issue struct { - ID githubv4.ID - Assignees struct { - Nodes []struct { - ID githubv4.ID - } - } `graphql:"assignees(first: 100)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "number": githubv4.Int(123), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "id": githubv4.ID("test-repo-id"), - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "assignees": map[string]any{ - "nodes": []any{}, - }, - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - UpdateIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - } - } `graphql:"updateIssue(input: $input)"` - }{}, - UpdateIssueInput{ - ID: githubv4.ID("test-issue-id"), - AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, - AgentAssignment: &AgentAssignmentInput{ - BaseRef: nil, - CustomAgent: ptrGitHubv4String(""), - CustomInstructions: ptrGitHubv4String("Please ensure all code follows PEP 8 style guidelines and includes comprehensive docstrings"), - TargetRepositoryID: githubv4.ID("test-repo-id"), - }, - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "updateIssue": map[string]any{ - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "number": githubv4.Int(123), - "url": githubv4.String("https://github.com/owner/repo/issues/123"), - }, - }, - }), - ), ), + expectToolError: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - - t.Parallel() - // Setup client with mock - client := githubv4.NewClient(tc.mockedClient) + gqlClient := githubv4.NewClient(tc.mockedClient) + client := mustNewGHClient(t, nil) deps := BaseDeps{ - GQLClient: client, + Client: client, + GQLClient: gqlClient, + RepoAccessCache: stubRepoAccessCache(nil, 15*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), } handler := serverTool.Handler(deps) - // Create call request request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) - // Disable polling in tests to avoid timeouts - ctx := ContextWithPollConfig(context.Background(), PollConfig{MaxAttempts: 0}) - ctx = ContextWithDeps(ctx, deps) - - // Call handler - result, err := handler(ctx, &request) require.NoError(t, err) - - textContent := getTextResult(t, result) + assert.NotNil(t, result) if tc.expectToolError { - require.True(t, result.IsError) - assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) - return + assert.True(t, result.IsError) + if tc.expectedToolErrMsg != "" { + textContent := getErrorResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + } + } else { + assert.False(t, result.IsError) } - - require.False(t, result.IsError, fmt.Sprintf("expected there to be no tool error, text was %s", textContent.Text)) - - // Verify the JSON response contains expected fields - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err, "response should be valid JSON") - assert.Equal(t, float64(123), response["issue_number"]) - assert.Equal(t, "https://github.com/owner/repo/issues/123", response["issue_url"]) - assert.Equal(t, "owner", response["owner"]) - assert.Equal(t, "repo", response["repo"]) - assert.Contains(t, response["message"], "successfully assigned copilot to issue") }) } } @@ -2834,7 +3860,7 @@ func Test_AddSubIssue(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedIssue *github.Issue expectedErrMsg string @@ -2844,7 +3870,7 @@ func Test_AddSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "add", "owner": "owner", "repo": "repo", @@ -2860,7 +3886,7 @@ func Test_AddSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "add", "owner": "owner", "repo": "repo", @@ -2875,7 +3901,7 @@ func Test_AddSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "add", "owner": "owner", "repo": "repo", @@ -2891,7 +3917,7 @@ func Test_AddSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "add", "owner": "owner", "repo": "repo", @@ -2906,7 +3932,7 @@ func Test_AddSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "add", "owner": "owner", "repo": "repo", @@ -2921,7 +3947,7 @@ func Test_AddSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "add", "owner": "owner", "repo": "repo", @@ -2936,7 +3962,7 @@ func Test_AddSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "add", "owner": "owner", "repo": "repo", @@ -2949,7 +3975,7 @@ func Test_AddSubIssue(t *testing.T) { { name: "missing required parameter owner", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "add", "repo": "repo", "issue_number": float64(42), @@ -2961,7 +3987,7 @@ func Test_AddSubIssue(t *testing.T) { { name: "missing required parameter sub_issue_id", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "add", "owner": "owner", "repo": "repo", @@ -2975,7 +4001,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, } @@ -3073,7 +4099,7 @@ func Test_GetSubIssues(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedSubIssues []*github.Issue expectedErrMsg string @@ -3083,7 +4109,7 @@ func Test_GetSubIssues(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockSubIssues), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_sub_issues", "owner": "owner", "repo": "repo", @@ -3102,7 +4128,7 @@ func Test_GetSubIssues(t *testing.T) { mockResponse(t, http.StatusOK, mockSubIssues), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_sub_issues", "owner": "owner", "repo": "repo", @@ -3118,7 +4144,7 @@ func Test_GetSubIssues(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, []*github.Issue{}), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_sub_issues", "owner": "owner", "repo": "repo", @@ -3132,7 +4158,7 @@ func Test_GetSubIssues(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_sub_issues", "owner": "owner", "repo": "repo", @@ -3146,7 +4172,7 @@ func Test_GetSubIssues(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_sub_issues", "owner": "nonexistent", "repo": "repo", @@ -3160,7 +4186,7 @@ func Test_GetSubIssues(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_sub_issues", "owner": "owner", "repo": "repo", @@ -3172,7 +4198,7 @@ func Test_GetSubIssues(t *testing.T) { { name: "missing required parameter owner", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_sub_issues", "repo": "repo", "issue_number": float64(42), @@ -3183,7 +4209,7 @@ func Test_GetSubIssues(t *testing.T) { { name: "missing required parameter issue_number", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_sub_issues", "owner": "owner", "repo": "repo", @@ -3196,12 +4222,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) @@ -3291,7 +4317,7 @@ func Test_RemoveSubIssue(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedIssue *github.Issue expectedErrMsg string @@ -3301,7 +4327,7 @@ func Test_RemoveSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "remove", "owner": "owner", "repo": "repo", @@ -3316,7 +4342,7 @@ func Test_RemoveSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "remove", "owner": "owner", "repo": "repo", @@ -3331,7 +4357,7 @@ func Test_RemoveSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "remove", "owner": "owner", "repo": "repo", @@ -3346,7 +4372,7 @@ func Test_RemoveSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "remove", "owner": "owner", "repo": "repo", @@ -3361,7 +4387,7 @@ func Test_RemoveSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "remove", "owner": "nonexistent", "repo": "repo", @@ -3376,7 +4402,7 @@ func Test_RemoveSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "remove", "owner": "owner", "repo": "repo", @@ -3389,7 +4415,7 @@ func Test_RemoveSubIssue(t *testing.T) { { name: "missing required parameter owner", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "remove", "repo": "repo", "issue_number": float64(42), @@ -3401,7 +4427,7 @@ func Test_RemoveSubIssue(t *testing.T) { { name: "missing required parameter sub_issue_id", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "remove", "owner": "owner", "repo": "repo", @@ -3415,7 +4441,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, } @@ -3499,7 +4525,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedIssue *github.Issue expectedErrMsg string @@ -3509,7 +4535,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "reprioritize", "owner": "owner", "repo": "repo", @@ -3525,7 +4551,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "reprioritize", "owner": "owner", "repo": "repo", @@ -3539,7 +4565,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { { name: "validation error - neither after_id nor before_id specified", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "reprioritize", "owner": "owner", "repo": "repo", @@ -3552,7 +4578,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { { name: "validation error - both after_id and before_id specified", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "reprioritize", "owner": "owner", "repo": "repo", @@ -3569,7 +4595,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "reprioritize", "owner": "owner", "repo": "repo", @@ -3585,7 +4611,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "reprioritize", "owner": "owner", "repo": "repo", @@ -3601,7 +4627,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "reprioritize", "owner": "owner", "repo": "repo", @@ -3617,7 +4643,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "reprioritize", "owner": "owner", "repo": "repo", @@ -3633,7 +4659,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "reprioritize", "owner": "owner", "repo": "repo", @@ -3647,7 +4673,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { { name: "missing required parameter owner", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "reprioritize", "repo": "repo", "issue_number": float64(42), @@ -3660,7 +4686,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { { name: "missing required parameter sub_issue_id", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "reprioritize", "owner": "owner", "repo": "repo", @@ -3675,7 +4701,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, } @@ -3750,7 +4776,7 @@ func Test_ListIssueTypes(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedIssueTypes []*github.IssueType expectedErrMsg string @@ -3760,7 +4786,7 @@ func Test_ListIssueTypes(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ "GET /orgs/testorg/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "testorg", }, expectError: false, @@ -3771,7 +4797,7 @@ func Test_ListIssueTypes(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ "GET /orgs/nonexistent/issue-types": mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "nonexistent", }, expectError: true, @@ -3782,16 +4808,40 @@ func Test_ListIssueTypes(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ "GET /orgs/testorg/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes), }), - requestArgs: map[string]interface{}{}, + requestArgs: map[string]any{}, expectError: false, // This should be handled by parameter validation, error returned in result expectedErrMsg: "missing required parameter: owner", }, + { + name: "successful repo issue types retrieval", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/testorg/testrepo/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes), + }), + requestArgs: map[string]any{ + "owner": "testorg", + "repo": "testrepo", + }, + expectError: false, + expectedIssueTypes: mockIssueTypes, + }, + { + name: "repo not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/testorg/nonexistent/issue-types": mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), + requestArgs: map[string]any{ + "owner": "testorg", + "repo": "nonexistent", + }, + expectError: true, + expectedErrMsg: "failed to list issue types", + }, } 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..0e49968496 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -7,6 +7,7 @@ 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/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -24,7 +25,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{ @@ -105,7 +106,11 @@ func GetLabel(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal label: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + result := utils.NewToolResultText(string(out)) + // Labels are structural repo metadata defined by collaborators + // (trusted); confidentiality follows repo visibility. + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) + return result, nil, nil }, ) } @@ -126,7 +131,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{ @@ -204,7 +209,11 @@ func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal labels: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + result := utils.NewToolResultText(string(out)) + // Labels are structural repo metadata defined by collaborators + // (trusted); confidentiality follows repo visibility. + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) + return result, nil, nil }, ) } @@ -217,7 +226,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 c6a0ea8499..eff6edc133 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -1,7 +1,15 @@ package github import ( - "github.com/google/go-github/v79/github" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "github.com/google/go-github/v87/github" + + "github.com/github/github-mcp-server/pkg/sanitize" ) // MinimalUser is the output type for user and organization search results. @@ -47,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"` @@ -75,6 +108,27 @@ 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. +// Compared to MinimalCommitFile, it includes the patch diff and previous filename for renames. +type MinimalPRFile struct { + Filename string `json:"filename"` + Status string `json:"status,omitempty"` + Additions int `json:"additions,omitempty"` + Deletions int `json:"deletions,omitempty"` + Changes int `json:"changes,omitempty"` + Patch string `json:"patch,omitempty"` + PreviousFilename string `json:"previous_filename,omitempty"` +} + +// MinimalPullRequestCommit is the trimmed output type for commits listed on a pull request. +type MinimalPullRequestCommit struct { + SHA string `json:"sha"` + HTMLURL string `json:"html_url,omitempty"` + Message string `json:"message,omitempty"` + Author *MinimalCommitAuthor `json:"author,omitempty"` } // MinimalCommit is the trimmed output type for commit objects. @@ -88,6 +142,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"` @@ -108,6 +179,12 @@ type MinimalBranch struct { Protected bool `json:"protected"` } +// MinimalTag is the trimmed output type for tag objects. +type MinimalTag struct { + Name string `json:"name"` + SHA string `json:"sha"` +} + // MinimalResponse represents a minimal response for all CRUD operations. // Success is implicit in the HTTP response status, and all other information // can be derived from the URL or fetched separately if needed. @@ -116,6 +193,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"` @@ -134,8 +218,626 @@ 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"` + PlusOne int `json:"+1"` + MinusOne int `json:"-1"` + Laugh int `json:"laugh"` + Confused int `json:"confused"` + Heart int `json:"heart"` + Hooray int `json:"hooray"` + Rocket int `json:"rocket"` + 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"` + IssueFieldValues []MinimalIssueFieldValue `json:"issue_field_values,omitempty"` + FieldValues []MinimalFieldValue `json:"field_values,omitempty"` +} + +// MinimalIssuesResponse is the trimmed output for a paginated list of issues. +type MinimalIssuesResponse struct { + Issues []MinimalIssue `json:"issues"` + TotalCount int `json:"totalCount"` + PageInfo MinimalPageInfo `json:"pageInfo"` +} + +// MinimalIssueComment is the trimmed output type for issue comment objects to reduce verbosity. +type MinimalIssueComment struct { + ID int64 `json:"id"` + Body string `json:"body,omitempty"` + HTMLURL string `json:"html_url"` + User *MinimalUser `json:"user,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` + Reactions *MinimalReactions `json:"reactions,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + 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"` + Commit *MinimalFileCommit `json:"commit,omitempty"` +} + +// MinimalFileContent is the trimmed content portion of a file operation response. +type MinimalFileContent struct { + Name string `json:"name"` + Path string `json:"path"` + SHA string `json:"sha"` + Size int `json:"size,omitempty"` + HTMLURL string `json:"html_url"` +} + +// MinimalFileCommit is the trimmed commit portion of a file operation response. +type MinimalFileCommit struct { + SHA string `json:"sha"` + Message string `json:"message,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + Author *MinimalCommitAuthor `json:"author,omitempty"` +} + +// MinimalPullRequest is the trimmed output type for pull request objects to reduce verbosity. +type MinimalPullRequest struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + State string `json:"state"` + Draft bool `json:"draft"` + Merged bool `json:"merged"` + MergeableState string `json:"mergeable_state,omitempty"` + HTMLURL string `json:"html_url"` + User *MinimalUser `json:"user,omitempty"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees,omitempty"` + RequestedReviewers []string `json:"requested_reviewers,omitempty"` + MergedBy string `json:"merged_by,omitempty"` + Head *MinimalPRBranch `json:"head,omitempty"` + Base *MinimalPRBranch `json:"base,omitempty"` + Additions int `json:"additions,omitempty"` + Deletions int `json:"deletions,omitempty"` + ChangedFiles int `json:"changed_files,omitempty"` + Commits int `json:"commits,omitempty"` + Comments int `json:"comments,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"` + Milestone string `json:"milestone,omitempty"` +} + +// MinimalPRBranch is the trimmed output type for pull request branch references. +type MinimalPRBranch struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + Repo *MinimalPRBranchRepo `json:"repo,omitempty"` +} + +// MinimalPRBranchRepo is the trimmed repo info nested inside a PR branch. +type MinimalPRBranchRepo struct { + FullName string `json:"full_name"` + Description string `json:"description,omitempty"` +} + +type MinimalProjectStatusUpdate struct { + ID string `json:"id"` + Body string `json:"body,omitempty"` + Status string `json:"status,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + StartDate string `json:"start_date,omitempty"` + TargetDate string `json:"target_date,omitempty"` + Creator *MinimalUser `json:"creator,omitempty"` +} + +// MinimalPullRequestReview is the trimmed output type for pull request review objects to reduce verbosity. +type MinimalPullRequestReview struct { + ID int64 `json:"id"` + State string `json:"state"` + Body string `json:"body,omitempty"` + HTMLURL string `json:"html_url"` + User *MinimalUser `json:"user,omitempty"` + CommitID string `json:"commit_id,omitempty"` + SubmittedAt string `json:"submitted_at,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` +} + // Helper functions +func convertToMinimalPullRequestReview(review *github.PullRequestReview) MinimalPullRequestReview { + m := MinimalPullRequestReview{ + ID: review.GetID(), + State: review.GetState(), + Body: review.GetBody(), + HTMLURL: review.GetHTMLURL(), + User: convertToMinimalUser(review.GetUser()), + CommitID: review.GetCommitID(), + AuthorAssociation: review.GetAuthorAssociation(), + } + + if review.SubmittedAt != nil { + m.SubmittedAt = review.SubmittedAt.Format(time.RFC3339) + } + + return m +} + +func convertToMinimalIssue(issue *github.Issue) MinimalIssue { + m := MinimalIssue{ + Number: issue.GetNumber(), + Title: issue.GetTitle(), + Body: issue.GetBody(), + State: issue.GetState(), + StateReason: issue.GetStateReason(), + Draft: issue.GetDraft(), + Locked: issue.GetLocked(), + HTMLURL: issue.GetHTMLURL(), + User: convertToMinimalUser(issue.GetUser()), + AuthorAssociation: issue.GetAuthorAssociation(), + Comments: issue.GetComments(), + } + + if issue.CreatedAt != nil { + m.CreatedAt = issue.CreatedAt.Format(time.RFC3339) + } + if issue.UpdatedAt != nil { + m.UpdatedAt = issue.UpdatedAt.Format(time.RFC3339) + } + if issue.ClosedAt != nil { + m.ClosedAt = issue.ClosedAt.Format(time.RFC3339) + } + + for _, label := range issue.Labels { + if label != nil { + m.Labels = append(m.Labels, label.GetName()) + } + } + + for _, assignee := range issue.Assignees { + if assignee != nil { + m.Assignees = append(m.Assignees, assignee.GetLogin()) + } + } + + if closedBy := issue.GetClosedBy(); closedBy != nil { + m.ClosedBy = closedBy.GetLogin() + } + + if milestone := issue.GetMilestone(); milestone != nil { + m.Milestone = milestone.GetTitle() + } + + if issueType := issue.GetType(); issueType != nil { + 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(), + PlusOne: r.GetPlusOne(), + MinusOne: r.GetMinusOne(), + Laugh: r.GetLaugh(), + Confused: r.GetConfused(), + Heart: r.GetHeart(), + Hooray: r.GetHooray(), + Rocket: r.GetRocket(), + Eyes: r.GetEyes(), + } + } + + return m +} + +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), + User: &MinimalUser{ + Login: string(fragment.Author.Login), + }, + } + + for _, label := range fragment.Labels.Nodes { + 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 { + minimalIssues = append(minimalIssues, fragmentToMinimalIssue(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), + }, + } +} + +// 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), + 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(), + Body: comment.GetBody(), + HTMLURL: comment.GetHTMLURL(), + User: convertToMinimalUser(comment.GetUser()), + AuthorAssociation: comment.GetAuthorAssociation(), + } + + if comment.CreatedAt != nil { + m.CreatedAt = comment.CreatedAt.Format(time.RFC3339) + } + if comment.UpdatedAt != nil { + m.UpdatedAt = comment.UpdatedAt.Format(time.RFC3339) + } + + if r := comment.Reactions; r != nil { + m.Reactions = &MinimalReactions{ + TotalCount: r.GetTotalCount(), + PlusOne: r.GetPlusOne(), + MinusOne: r.GetMinusOne(), + Laugh: r.GetLaugh(), + Confused: r.GetConfused(), + Heart: r.GetHeart(), + Hooray: r.GetHooray(), + Rocket: r.GetRocket(), + Eyes: r.GetEyes(), + } + } + + return m +} + +func convertToMinimalFileContentResponse(resp *github.RepositoryContentResponse) MinimalFileContentResponse { + m := MinimalFileContentResponse{} + + if resp == nil { + return m + } + + if c := resp.Content; c != nil { + m.Content = &MinimalFileContent{ + Name: c.GetName(), + Path: c.GetPath(), + SHA: c.GetSHA(), + Size: c.GetSize(), + HTMLURL: c.GetHTMLURL(), + } + } + + m.Commit = &MinimalFileCommit{ + SHA: resp.Commit.GetSHA(), + Message: resp.Commit.GetMessage(), + HTMLURL: resp.Commit.GetHTMLURL(), + } + + if author := resp.Commit.Author; author != nil { + m.Commit.Author = &MinimalCommitAuthor{ + Name: author.GetName(), + Email: author.GetEmail(), + } + if author.Date != nil { + m.Commit.Author.Date = author.Date.Format(time.RFC3339) + } + } + + return m +} + +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(), + Deletions: pr.GetDeletions(), + ChangedFiles: pr.GetChangedFiles(), + Commits: pr.GetCommits(), + Comments: pr.GetComments(), + } + + if pr.CreatedAt != nil { + m.CreatedAt = pr.CreatedAt.Format(time.RFC3339) + } + if pr.UpdatedAt != nil { + m.UpdatedAt = pr.UpdatedAt.Format(time.RFC3339) + } + if pr.ClosedAt != nil { + m.ClosedAt = pr.ClosedAt.Format(time.RFC3339) + } + if pr.MergedAt != nil { + m.MergedAt = pr.MergedAt.Format(time.RFC3339) + } + + for _, label := range pr.Labels { + if label != nil { + m.Labels = append(m.Labels, label.GetName()) + } + } + + for _, assignee := range pr.Assignees { + if assignee != nil { + m.Assignees = append(m.Assignees, assignee.GetLogin()) + } + } + + for _, reviewer := range pr.RequestedReviewers { + if reviewer != nil { + m.RequestedReviewers = append(m.RequestedReviewers, reviewer.GetLogin()) + } + } + + if mergedBy := pr.GetMergedBy(); mergedBy != nil { + m.MergedBy = mergedBy.GetLogin() + } + + if head := pr.Head; head != nil { + m.Head = convertToMinimalPRBranch(head) + } + + if base := pr.Base; base != nil { + m.Base = convertToMinimalPRBranch(base) + } + + if milestone := pr.GetMilestone(); milestone != nil { + m.Milestone = milestone.GetTitle() + } + + return m +} + +func convertToMinimalPRBranch(branch *github.PullRequestBranch) *MinimalPRBranch { + if branch == nil { + return nil + } + + b := &MinimalPRBranch{ + Ref: branch.GetRef(), + SHA: branch.GetSHA(), + } + + if repo := branch.GetRepo(); repo != nil { + b.Repo = &MinimalPRBranchRepo{ + FullName: repo.GetFullName(), + Description: repo.GetDescription(), + } + } + + return b +} + func convertToMinimalProject(fullProject *github.ProjectV2) *MinimalProject { if fullProject == nil { return nil @@ -159,6 +861,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 @@ -172,85 +1415,246 @@ 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("2006-01-02T15:04:05Z") + 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("2006-01-02T15:04:05Z") + 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"` + HasPreviousPage bool `json:"hasPreviousPage"` + StartCursor string `json:"startCursor,omitempty"` + EndCursor string `json:"endCursor,omitempty"` +} + +// MinimalReviewComment is the trimmed output type for PR review comment objects. +type MinimalReviewComment struct { + Body string `json:"body,omitempty"` + Path string `json:"path"` + Line *int `json:"line,omitempty"` + Author string `json:"author,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + HTMLURL string `json:"html_url"` +} + +// 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"` + Comments []MinimalReviewComment `json:"comments"` + TotalCount int `json:"total_count"` +} + +// MinimalReviewThreadsResponse is the trimmed output for a paginated list of PR review threads. +type MinimalReviewThreadsResponse struct { + ReviewThreads []MinimalReviewThread `json:"review_threads"` + TotalCount int `json:"totalCount"` + PageInfo MinimalPageInfo `json:"pageInfo"` +} + +func convertToMinimalPRFiles(files []*github.CommitFile) []MinimalPRFile { + result := make([]MinimalPRFile, 0, len(files)) + for _, f := range files { + result = append(result, MinimalPRFile{ + Filename: f.GetFilename(), + Status: f.GetStatus(), + Additions: f.GetAdditions(), + Deletions: f.GetDeletions(), + Changes: f.GetChanges(), + Patch: f.GetPatch(), + PreviousFilename: f.GetPreviousFilename(), + }) + } + return result +} + +func convertToMinimalPullRequestCommits(commits []*github.RepositoryCommit) []MinimalPullRequestCommit { + result := make([]MinimalPullRequestCommit, 0, len(commits)) + for _, commit := range commits { + if commit == nil { + continue + } + + minimalCommit := MinimalPullRequestCommit{ + SHA: commit.GetSHA(), + HTMLURL: commit.GetHTMLURL(), + } + + if commit.Commit != nil { + minimalCommit.Message = commit.Commit.GetMessage() + minimalCommit.Author = convertToMinimalCommitAuthor(commit.Commit.Author) + } + + result = append(result, minimalCommit) + } + return result +} + +func convertToMinimalCommitAuthor(author *github.CommitAuthor) *MinimalCommitAuthor { + if author == nil { + return nil + } + + minimalAuthor := &MinimalCommitAuthor{ + Name: author.GetName(), + Email: author.GetEmail(), + } + if author.Date != nil { + minimalAuthor.Date = author.Date.Format(time.RFC3339) + } + + return minimalAuthor +} + // convertToMinimalBranch converts a GitHub API Branch to MinimalBranch func convertToMinimalBranch(branch *github.Branch) MinimalBranch { return MinimalBranch{ @@ -259,3 +1663,132 @@ func convertToMinimalBranch(branch *github.Branch) MinimalBranch { Protected: branch.GetProtected(), } } + +func convertToMinimalRelease(release *github.RepositoryRelease) MinimalRelease { + m := MinimalRelease{ + ID: release.GetID(), + TagName: release.GetTagName(), + Name: release.GetName(), + Body: release.GetBody(), + HTMLURL: release.GetHTMLURL(), + Prerelease: release.GetPrerelease(), + Draft: release.GetDraft(), + Author: convertToMinimalUser(release.GetAuthor()), + } + + if release.PublishedAt != nil { + m.PublishedAt = release.PublishedAt.Format(time.RFC3339) + } + + return m +} + +func convertToMinimalTag(tag *github.RepositoryTag) MinimalTag { + m := MinimalTag{ + Name: tag.GetName(), + } + + if commit := tag.GetCommit(); commit != nil { + m.SHA = commit.GetSHA() + } + + return m +} + +// MinimalCheckRun is the trimmed output type for check run objects. +type MinimalCheckRun struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + DetailsURL string `json:"details_url,omitempty"` + StartedAt string `json:"started_at,omitempty"` + CompletedAt string `json:"completed_at,omitempty"` +} + +// MinimalCheckRunsResult is the trimmed output type for check runs list results. +type MinimalCheckRunsResult struct { + TotalCount int `json:"total_count"` + CheckRuns []MinimalCheckRun `json:"check_runs"` +} + +// convertToMinimalCheckRun converts a GitHub API CheckRun to MinimalCheckRun +func convertToMinimalCheckRun(checkRun *github.CheckRun) MinimalCheckRun { + minimalCheckRun := MinimalCheckRun{ + ID: checkRun.GetID(), + Name: checkRun.GetName(), + Status: checkRun.GetStatus(), + Conclusion: checkRun.GetConclusion(), + HTMLURL: checkRun.GetHTMLURL(), + DetailsURL: checkRun.GetDetailsURL(), + } + + if checkRun.StartedAt != nil { + minimalCheckRun.StartedAt = checkRun.StartedAt.Format("2006-01-02T15:04:05Z") + } + if checkRun.CompletedAt != nil { + minimalCheckRun.CompletedAt = checkRun.CompletedAt.Format("2006-01-02T15:04:05Z") + } + + return minimalCheckRun +} + +func convertToMinimalReviewThreadsResponse(query reviewThreadsQuery) MinimalReviewThreadsResponse { + threads := query.Repository.PullRequest.ReviewThreads + + minimalThreads := make([]MinimalReviewThread, 0, len(threads.Nodes)) + for _, thread := range threads.Nodes { + minimalThreads = append(minimalThreads, convertToMinimalReviewThread(thread)) + } + + return MinimalReviewThreadsResponse{ + ReviewThreads: minimalThreads, + TotalCount: int(threads.TotalCount), + PageInfo: MinimalPageInfo{ + HasNextPage: bool(threads.PageInfo.HasNextPage), + HasPreviousPage: bool(threads.PageInfo.HasPreviousPage), + StartCursor: string(threads.PageInfo.StartCursor), + EndCursor: string(threads.PageInfo.EndCursor), + }, + } +} + +func convertToMinimalReviewThread(thread reviewThreadNode) MinimalReviewThread { + comments := make([]MinimalReviewComment, 0, len(thread.Comments.Nodes)) + for _, c := range thread.Comments.Nodes { + comments = append(comments, convertToMinimalReviewComment(c)) + } + + return MinimalReviewThread{ + ID: fmt.Sprintf("%v", thread.ID), + IsResolved: bool(thread.IsResolved), + IsOutdated: bool(thread.IsOutdated), + IsCollapsed: bool(thread.IsCollapsed), + Comments: comments, + TotalCount: int(thread.Comments.TotalCount), + } +} + +func convertToMinimalReviewComment(c reviewCommentNode) MinimalReviewComment { + m := MinimalReviewComment{ + Body: string(c.Body), + Path: string(c.Path), + Author: string(c.Author.Login), + HTMLURL: c.URL.String(), + } + + if c.Line != nil { + line := int(*c.Line) + m.Line = &line + } + + if !c.CreatedAt.IsZero() { + m.CreatedAt = c.CreatedAt.Format(time.RFC3339) + } + if !c.UpdatedAt.IsZero() { + m.UpdatedAt = c.UpdatedAt.Format(time.RFC3339) + } + + return m +} diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index 1d695beb3b..1504757a7f 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -6,15 +6,15 @@ import ( "fmt" "io" "net/http" - "strconv" "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" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -209,13 +209,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: @@ -393,7 +387,13 @@ func GetNotificationDetails(t translations.TranslationHelperFunc) inventory.Serv return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // A notification subject points at an issue, PR, comment, or + // discussion whose content is user-authored (untrusted). It is + // delivered to a specific recipient and may reference private + // repositories, so confidentiality is private. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelNotificationDetails()) + return result, nil, nil }, ) } diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go index d2124ae3d8..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/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -42,7 +42,7 @@ func Test_ListNotifications(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedResult []*github.Notification expectedErrMsg string @@ -52,7 +52,7 @@ func Test_ListNotifications(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetNotifications: mockResponse(t, http.StatusOK, []*github.Notification{mockNotification}), }), - requestArgs: map[string]interface{}{}, + requestArgs: map[string]any{}, expectError: false, expectedResult: []*github.Notification{mockNotification}, }, @@ -61,7 +61,7 @@ func Test_ListNotifications(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetNotifications: mockResponse(t, http.StatusOK, []*github.Notification{mockNotification}), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "filter": "include_read_notifications", }, expectError: false, @@ -72,7 +72,7 @@ func Test_ListNotifications(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetNotifications: mockResponse(t, http.StatusOK, []*github.Notification{mockNotification}), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "filter": "only_participating", }, expectError: false, @@ -83,7 +83,7 @@ func Test_ListNotifications(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposNotificationsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.Notification{mockNotification}), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "filter": "default", "since": "2024-01-01T00:00:00Z", "before": "2024-01-02T00:00:00Z", @@ -100,7 +100,7 @@ func Test_ListNotifications(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetNotifications: mockResponse(t, http.StatusInternalServerError, `{"message": "error"}`), }), - requestArgs: map[string]interface{}{}, + requestArgs: map[string]any{}, expectError: true, expectedErrMsg: "error", }, @@ -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, } @@ -159,7 +159,7 @@ func Test_ManageNotificationSubscription(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectIgnored *bool expectDeleted bool @@ -171,7 +171,7 @@ func Test_ManageNotificationSubscription(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PutNotificationsThreadsSubscriptionByThreadID: mockResponse(t, http.StatusOK, mockSub), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "notificationID": "123", "action": "ignore", }, @@ -183,7 +183,7 @@ func Test_ManageNotificationSubscription(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PutNotificationsThreadsSubscriptionByThreadID: mockResponse(t, http.StatusOK, mockSubWatch), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "notificationID": "123", "action": "watch", }, @@ -195,7 +195,7 @@ func Test_ManageNotificationSubscription(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ DeleteNotificationsThreadsSubscriptionByThreadID: mockResponse(t, http.StatusOK, nil), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "notificationID": "123", "action": "delete", }, @@ -205,7 +205,7 @@ func Test_ManageNotificationSubscription(t *testing.T) { { name: "invalid action", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "notificationID": "123", "action": "invalid", }, @@ -215,7 +215,7 @@ func Test_ManageNotificationSubscription(t *testing.T) { { name: "missing required notificationID", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "action": "ignore", }, expectError: true, @@ -223,7 +223,7 @@ func Test_ManageNotificationSubscription(t *testing.T) { { name: "missing required action", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "notificationID": "123", }, expectError: true, @@ -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, } @@ -296,7 +296,7 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectIgnored *bool expectSubscribed *bool @@ -309,7 +309,7 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PutReposSubscriptionByOwnerByRepo: mockResponse(t, http.StatusOK, mockSub), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "action": "ignore", @@ -322,7 +322,7 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PutReposSubscriptionByOwnerByRepo: mockResponse(t, http.StatusOK, mockWatchSub), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "action": "watch", @@ -336,7 +336,7 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ DeleteReposSubscriptionByOwnerByRepo: mockResponse(t, http.StatusOK, nil), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "action": "delete", @@ -347,7 +347,7 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { { name: "invalid action", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "action": "invalid", @@ -358,7 +358,7 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { { name: "missing required owner", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "repo": "repo", "action": "ignore", }, @@ -367,7 +367,7 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { { name: "missing required repo", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "action": "ignore", }, @@ -376,7 +376,7 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { { name: "missing required action", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -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, } @@ -452,11 +452,10 @@ func Test_DismissNotification(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectRead bool expectDone bool - expectInvalid bool expectedErrMsg string }{ { @@ -464,7 +463,7 @@ func Test_DismissNotification(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchNotificationsThreadsByThreadID: mockResponse(t, http.StatusOK, nil), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "threadID": "123", "state": "read", }, @@ -476,7 +475,7 @@ func Test_DismissNotification(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ DeleteNotificationsThreadsByThreadID: mockResponse(t, http.StatusNoContent, nil), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "threadID": "123", "state": "done", }, @@ -488,27 +487,17 @@ func Test_DismissNotification(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ DeleteNotificationsThreadsByThreadID: mockResponse(t, http.StatusOK, nil), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "threadID": "123", "state": "done", }, expectError: false, expectDone: true, }, - { - name: "invalid threadID format", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ - "threadID": "notanumber", - "state": "done", - }, - expectError: false, - expectInvalid: true, - }, { name: "missing required threadID", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "state": "read", }, expectError: true, @@ -516,7 +505,7 @@ func Test_DismissNotification(t *testing.T) { { name: "missing required state", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "threadID": "123", }, expectError: true, @@ -524,7 +513,7 @@ func Test_DismissNotification(t *testing.T) { { name: "invalid state value", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "threadID": "123", "state": "invalid", }, @@ -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") - } }) } } @@ -597,7 +581,7 @@ func Test_MarkAllNotificationsRead(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectMarked bool expectedErrMsg string @@ -607,7 +591,7 @@ func Test_MarkAllNotificationsRead(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PutNotifications: mockResponse(t, http.StatusOK, nil), }), - requestArgs: map[string]interface{}{}, + requestArgs: map[string]any{}, expectError: false, expectMarked: true, }, @@ -616,7 +600,7 @@ func Test_MarkAllNotificationsRead(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PutNotifications: mockResponse(t, http.StatusOK, nil), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "lastReadAt": "2024-01-01T00:00:00Z", }, expectError: false, @@ -627,7 +611,7 @@ func Test_MarkAllNotificationsRead(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PutReposNotificationsByOwnerByRepo: mockResponse(t, http.StatusOK, nil), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "octocat", "repo": "hello-world", }, @@ -639,7 +623,7 @@ func Test_MarkAllNotificationsRead(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PutNotifications: mockResponse(t, http.StatusInternalServerError, `{"message": "error"}`), }), - requestArgs: map[string]interface{}{}, + requestArgs: map[string]any{}, expectError: true, expectedErrMsg: "error", }, @@ -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, } @@ -694,7 +678,7 @@ func Test_GetNotificationDetails(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectResult *github.Notification expectedErrMsg string @@ -704,7 +688,7 @@ func Test_GetNotificationDetails(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetNotificationsThreadsByThreadID: mockResponse(t, http.StatusOK, mockThread), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "notificationID": "123", }, expectError: false, @@ -715,7 +699,7 @@ func Test_GetNotificationDetails(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetNotificationsThreadsByThreadID: mockResponse(t, http.StatusNotFound, `{"message": "not found"}`), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "notificationID": "123", }, expectError: true, @@ -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 new file mode 100644 index 0000000000..a6b43375ef --- /dev/null +++ b/pkg/github/params.go @@ -0,0 +1,490 @@ +package github + +import ( + "errors" + "fmt" + "math" + "strconv" + + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" +) + +// OptionalParamOK is a helper function that can be used to fetch a requested parameter from the request. +// It returns the value, a boolean indicating if the parameter was present, and an error if the type is wrong. +func OptionalParamOK[T any, A map[string]any](args A, p string) (value T, ok bool, err error) { + // Check if the parameter is present in the request + val, exists := args[p] + if !exists { + // Not present, return zero value, false, no error + return + } + + // Check if the parameter is of the expected type + value, ok = val.(T) + if !ok { + // Present but wrong type + err = fmt.Errorf("parameter %s is not of type %T, is %T", p, value, val) + ok = true // Set ok to true because the parameter *was* present, even if wrong type + return + } + + // Present and correct type + ok = true + return +} + +// isAcceptedError checks if the error is an accepted error. +func isAcceptedError(err error) bool { + var acceptedError *github.AcceptedError + return errors.As(err, &acceptedError) +} + +// toInt converts a value to int, handling both float64 and string representations. +// Some MCP clients send numeric values as strings. It rejects NaN, ±Inf, +// fractional values, and values outside the int range. +func toInt(val any) (int, error) { + var f float64 + switch v := val.(type) { + case float64: + f = v + case string: + var err error + f, err = strconv.ParseFloat(v, 64) + if err != nil { + return 0, fmt.Errorf("invalid numeric value: %s", v) + } + default: + return 0, fmt.Errorf("expected number, got %T", val) + } + if math.IsNaN(f) || math.IsInf(f, 0) { + return 0, fmt.Errorf("non-finite numeric value") + } + if f != math.Trunc(f) { + return 0, fmt.Errorf("non-integer numeric value: %v", f) + } + if f > math.MaxInt || f < math.MinInt { + return 0, fmt.Errorf("numeric value out of int range: %v", f) + } + return int(f), nil +} + +// toInt64 converts a value to int64, handling both float64 and string representations. +// Some MCP clients send numeric values as strings. It rejects NaN, ±Inf, +// fractional values, and values that lose precision in the float64→int64 conversion. +func toInt64(val any) (int64, error) { + var f float64 + switch v := val.(type) { + case float64: + f = v + case string: + var err error + f, err = strconv.ParseFloat(v, 64) + if err != nil { + return 0, fmt.Errorf("invalid numeric value: %s", v) + } + default: + return 0, fmt.Errorf("expected number, got %T", val) + } + if math.IsNaN(f) || math.IsInf(f, 0) { + return 0, fmt.Errorf("non-finite numeric value") + } + if f != math.Trunc(f) { + return 0, fmt.Errorf("non-integer numeric value: %v", f) + } + result := int64(f) + // Check round-trip to detect precision loss for large int64 values + if float64(result) != f { + return 0, fmt.Errorf("numeric value %v is too large to fit in int64", f) + } + return result, nil +} + +// RequiredParam is a helper function that can be used to fetch a requested parameter from the request. +// It does the following checks: +// 1. Checks if the parameter is present in the request. +// 2. Checks if the parameter is of the expected type. +// 3. Checks if the parameter is not empty, i.e: non-zero value +func RequiredParam[T comparable](args map[string]any, p string) (T, error) { + var zero T + + // Check if the parameter is present in the request + if _, ok := args[p]; !ok { + return zero, fmt.Errorf("missing required parameter: %s", p) + } + + // Check if the parameter is of the expected type + val, ok := args[p].(T) + if !ok { + return zero, fmt.Errorf("parameter %s is not of type %T", p, zero) + } + + if val == zero { + return zero, fmt.Errorf("missing required parameter: %s", p) + } + + return val, nil +} + +// RequiredInt is a helper function that can be used to fetch a requested parameter from the request. +// It does the following checks: +// 1. Checks if the parameter is present in the request. +// 2. Checks if the parameter is of the expected type (float64 or numeric string). +// 3. Checks if the parameter is not empty, i.e: non-zero value +func RequiredInt(args map[string]any, p string) (int, error) { + v, ok := args[p] + if !ok { + return 0, fmt.Errorf("missing required parameter: %s", p) + } + + result, err := toInt(v) + if err != nil { + return 0, fmt.Errorf("parameter %s is not a valid number: %w", p, err) + } + + if result == 0 { + return 0, fmt.Errorf("missing required parameter: %s", p) + } + + return result, nil +} + +// RequiredBigInt is a helper function that can be used to fetch a requested parameter from the request. +// It does the following checks: +// 1. Checks if the parameter is present in the request. +// 2. Checks if the parameter is of the expected type (float64 or numeric string). +// 3. Checks if the parameter is not empty, i.e: non-zero value. +// 4. Validates that the float64 value can be safely converted to int64 without truncation. +func RequiredBigInt(args map[string]any, p string) (int64, error) { + val, ok := args[p] + if !ok { + return 0, fmt.Errorf("missing required parameter: %s", p) + } + + result, err := toInt64(val) + if err != nil { + return 0, fmt.Errorf("parameter %s is not a valid number: %w", p, err) + } + + if result == 0 { + return 0, fmt.Errorf("missing required parameter: %s", p) + } + + return result, nil +} + +// OptionalParam is a helper function that can be used to fetch a requested parameter from the request. +// It does the following checks: +// 1. Checks if the parameter is present in the request, if not, it returns its zero-value +// 2. If it is present, it checks if the parameter is of the expected type and returns it +func OptionalParam[T any](args map[string]any, p string) (T, error) { + var zero T + + // Check if the parameter is present in the request + if _, ok := args[p]; !ok { + return zero, nil + } + + // Check if the parameter is of the expected type + if _, ok := args[p].(T); !ok { + return zero, fmt.Errorf("parameter %s is not of type %T, is %T", p, zero, args[p]) + } + + return args[p].(T), nil +} + +// OptionalIntParam is a helper function that can be used to fetch a requested parameter from the request. +// It does the following checks: +// 1. Checks if the parameter is present in the request, if not, it returns its zero-value +// 2. If it is present, it checks if the parameter is of the expected type (float64 or numeric string) and returns it +func OptionalIntParam(args map[string]any, p string) (int, error) { + val, ok := args[p] + if !ok { + return 0, nil + } + + result, err := toInt(val) + if err != nil { + return 0, fmt.Errorf("parameter %s is not a valid number: %w", p, err) + } + + return result, nil +} + +// OptionalIntParamWithDefault is a helper function that can be used to fetch a requested parameter from the request +// similar to optionalIntParam, but it also takes a default value. +func OptionalIntParamWithDefault(args map[string]any, p string, d int) (int, error) { + v, err := OptionalIntParam(args, p) + if err != nil { + return 0, err + } + if v == 0 { + return d, nil + } + return v, nil +} + +// OptionalBoolParamWithDefault is a helper function that can be used to fetch a requested parameter from the request +// similar to optionalBoolParam, but it also takes a default value. +func OptionalBoolParamWithDefault(args map[string]any, p string, d bool) (bool, error) { + _, ok := args[p] + v, err := OptionalParam[bool](args, p) + if err != nil { + return false, err + } + if !ok { + return d, nil + } + return v, nil +} + +// OptionalStringArrayParam is a helper function that can be used to fetch a requested parameter from the request. +// It does the following checks: +// 1. Checks if the parameter is present in the request, if not, it returns its zero-value +// 2. If it is present, iterates the elements and checks each is a string +func OptionalStringArrayParam(args map[string]any, p string) ([]string, error) { + // Check if the parameter is present in the request + if _, ok := args[p]; !ok { + return []string{}, nil + } + + switch v := args[p].(type) { + case nil: + return []string{}, nil + case []string: + return v, nil + case []any: + strSlice := make([]string, len(v)) + for i, v := range v { + s, ok := v.(string) + if !ok { + return []string{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v) + } + strSlice[i] = s + } + return strSlice, nil + default: + return []string{}, fmt.Errorf("parameter %s could not be coerced to []string, is %T", p, args[p]) + } +} + +func convertStringSliceToBigIntSlice(s []string) ([]int64, error) { + int64Slice := make([]int64, len(s)) + for i, str := range s { + val, err := convertStringToBigInt(str, 0) + if err != nil { + return nil, fmt.Errorf("failed to convert element %d (%s) to int64: %w", i, str, err) + } + int64Slice[i] = val + } + return int64Slice, nil +} + +func convertStringToBigInt(s string, def int64) (int64, error) { + v, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return def, fmt.Errorf("failed to convert string %s to int64: %w", s, err) + } + return v, nil +} + +// OptionalBigIntArrayParam is a helper function that can be used to fetch a requested parameter from the request. +// It does the following checks: +// 1. Checks if the parameter is present in the request, if not, it returns an empty slice +// 2. If it is present, iterates the elements, checks each is a string, and converts them to int64 values +func OptionalBigIntArrayParam(args map[string]any, p string) ([]int64, error) { + // Check if the parameter is present in the request + if _, ok := args[p]; !ok { + return []int64{}, nil + } + + switch v := args[p].(type) { + case nil: + return []int64{}, nil + case []string: + return convertStringSliceToBigIntSlice(v) + case []any: + int64Slice := make([]int64, len(v)) + for i, v := range v { + s, ok := v.(string) + if !ok { + return []int64{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v) + } + val, err := convertStringToBigInt(s, 0) + if err != nil { + return []int64{}, fmt.Errorf("parameter %s: failed to convert element %d (%s) to int64: %w", p, i, s, err) + } + int64Slice[i] = val + } + return int64Slice, nil + default: + return []int64{}, fmt.Errorf("parameter %s could not be coerced to []int64, is %T", p, args[p]) + } +} + +// WithPagination adds REST API pagination parameters to a tool. +// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api +func WithPagination(schema *jsonschema.Schema) *jsonschema.Schema { + schema.Properties["page"] = &jsonschema.Schema{ + Type: "number", + Description: "Page number for pagination (min 1)", + Minimum: jsonschema.Ptr(1.0), + } + + schema.Properties["perPage"] = &jsonschema.Schema{ + Type: "number", + Description: "Results per page for pagination (min 1, max 100)", + Minimum: jsonschema.Ptr(1.0), + Maximum: jsonschema.Ptr(100.0), + } + + return schema +} + +// WithUnifiedPagination adds REST API pagination parameters to a tool. +// GraphQL tools will use this and convert page/perPage to GraphQL cursor parameters internally. +func WithUnifiedPagination(schema *jsonschema.Schema) *jsonschema.Schema { + schema.Properties["page"] = &jsonschema.Schema{ + Type: "number", + Description: "Page number for pagination (min 1)", + Minimum: jsonschema.Ptr(1.0), + } + + schema.Properties["perPage"] = &jsonschema.Schema{ + Type: "number", + Description: "Results per page for pagination (min 1, max 100)", + Minimum: jsonschema.Ptr(1.0), + Maximum: jsonschema.Ptr(100.0), + } + + schema.Properties["after"] = &jsonschema.Schema{ + Type: "string", + Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + } + + return schema +} + +// WithCursorPagination adds only cursor-based pagination parameters to a tool (no page parameter). +func WithCursorPagination(schema *jsonschema.Schema) *jsonschema.Schema { + schema.Properties["perPage"] = &jsonschema.Schema{ + Type: "number", + Description: "Results per page for pagination (min 1, max 100)", + Minimum: jsonschema.Ptr(1.0), + Maximum: jsonschema.Ptr(100.0), + } + + schema.Properties["after"] = &jsonschema.Schema{ + Type: "string", + Description: "Cursor for pagination. Use the cursor from the previous response.", + } + + return schema +} + +type PaginationParams struct { + Page int + PerPage int + After string +} + +// OptionalPaginationParams returns the "page", "perPage", and "after" parameters from the request, +// or their default values if not present, "page" default is 1, "perPage" default is 30. +// In future, we may want to make the default values configurable, or even have this +// function returned from `withPagination`, where the defaults are provided alongside +// the min/max values. +func OptionalPaginationParams(args map[string]any) (PaginationParams, error) { + page, err := OptionalIntParamWithDefault(args, "page", 1) + if err != nil { + return PaginationParams{}, err + } + perPage, err := OptionalIntParamWithDefault(args, "perPage", 30) + if err != nil { + return PaginationParams{}, err + } + after, err := OptionalParam[string](args, "after") + if err != nil { + return PaginationParams{}, err + } + return PaginationParams{ + Page: page, + PerPage: perPage, + After: after, + }, nil +} + +// OptionalCursorPaginationParams returns the "perPage" and "after" parameters from the request, +// without the "page" parameter, suitable for cursor-based pagination only. +func OptionalCursorPaginationParams(args map[string]any) (CursorPaginationParams, error) { + perPage, err := OptionalIntParamWithDefault(args, "perPage", 30) + if err != nil { + return CursorPaginationParams{}, err + } + after, err := OptionalParam[string](args, "after") + if err != nil { + return CursorPaginationParams{}, err + } + return CursorPaginationParams{ + PerPage: perPage, + After: after, + }, nil +} + +type CursorPaginationParams struct { + PerPage int + After string +} + +type pageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + NextCursor string `json:"nextCursor,omitempty"` + PrevCursor string `json:"prevCursor,omitempty"` +} + +func buildPageInfo(resp *github.Response) pageInfo { + return pageInfo{ + HasNextPage: resp.After != "", + HasPreviousPage: resp.Before != "", + NextCursor: resp.After, + PrevCursor: resp.Before, + } +} + +// ToGraphQLParams converts cursor pagination parameters to GraphQL-specific parameters. +func (p CursorPaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) { + if p.PerPage > 100 { + return nil, fmt.Errorf("perPage value %d exceeds maximum of 100", p.PerPage) + } + if p.PerPage < 0 { + return nil, fmt.Errorf("perPage value %d cannot be negative", p.PerPage) + } + first := int32(p.PerPage) + + var after *string + if p.After != "" { + after = &p.After + } + + return &GraphQLPaginationParams{ + First: &first, + After: after, + }, nil +} + +type GraphQLPaginationParams struct { + First *int32 + After *string +} + +// ToGraphQLParams converts REST API pagination parameters to GraphQL-specific parameters. +// This converts page/perPage to first parameter for GraphQL queries. +// If After is provided, it takes precedence over page-based pagination. +func (p PaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) { + // Convert to CursorPaginationParams and delegate to avoid duplication + cursor := CursorPaginationParams{ + PerPage: p.PerPage, + After: p.After, + } + return cursor.ToGraphQLParams() +} diff --git a/pkg/github/params_test.go b/pkg/github/params_test.go new file mode 100644 index 0000000000..b00efeb10c --- /dev/null +++ b/pkg/github/params_test.go @@ -0,0 +1,644 @@ +package github + +import ( + "fmt" + "math" + "testing" + + "github.com/google/go-github/v87/github" + "github.com/stretchr/testify/assert" +) + +func Test_IsAcceptedError(t *testing.T) { + tests := []struct { + name string + err error + expectAccepted bool + }{ + { + name: "github AcceptedError", + err: &github.AcceptedError{}, + expectAccepted: true, + }, + { + name: "regular error", + err: fmt.Errorf("some other error"), + expectAccepted: false, + }, + { + name: "nil error", + err: nil, + expectAccepted: false, + }, + { + name: "wrapped AcceptedError", + err: fmt.Errorf("wrapped: %w", &github.AcceptedError{}), + expectAccepted: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := isAcceptedError(tc.err) + assert.Equal(t, tc.expectAccepted, result) + }) + } +} + +func Test_RequiredStringParam(t *testing.T) { + tests := []struct { + name string + params map[string]any + paramName string + expected string + expectError bool + }{ + { + name: "valid string parameter", + params: map[string]any{"name": "test-value"}, + paramName: "name", + expected: "test-value", + expectError: false, + }, + { + name: "missing parameter", + params: map[string]any{}, + paramName: "name", + expected: "", + expectError: true, + }, + { + name: "empty string parameter", + params: map[string]any{"name": ""}, + paramName: "name", + expected: "", + expectError: true, + }, + { + name: "wrong type parameter", + params: map[string]any{"name": 123}, + paramName: "name", + expected: "", + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := RequiredParam[string](tc.params, tc.paramName) + + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + } + }) + } +} + +func Test_OptionalStringParam(t *testing.T) { + tests := []struct { + name string + params map[string]any + paramName string + expected string + expectError bool + }{ + { + name: "valid string parameter", + params: map[string]any{"name": "test-value"}, + paramName: "name", + expected: "test-value", + expectError: false, + }, + { + name: "missing parameter", + params: map[string]any{}, + paramName: "name", + expected: "", + expectError: false, + }, + { + name: "empty string parameter", + params: map[string]any{"name": ""}, + paramName: "name", + expected: "", + expectError: false, + }, + { + name: "wrong type parameter", + params: map[string]any{"name": 123}, + paramName: "name", + expected: "", + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := OptionalParam[string](tc.params, tc.paramName) + + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + } + }) + } +} + +func Test_RequiredInt(t *testing.T) { + tests := []struct { + name string + params map[string]any + paramName string + expected int + expectError bool + }{ + { + name: "valid number parameter", + params: map[string]any{"count": float64(42)}, + paramName: "count", + expected: 42, + expectError: false, + }, + { + name: "valid string number parameter", + params: map[string]any{"count": "42"}, + paramName: "count", + expected: 42, + expectError: false, + }, + { + name: "missing parameter", + params: map[string]any{}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "zero string parameter", + params: map[string]any{"count": "0"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "wrong type parameter", + params: map[string]any{"count": "not-a-number"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "boolean type parameter", + params: map[string]any{"count": true}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "NaN string", + params: map[string]any{"count": "NaN"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "Inf string", + params: map[string]any{"count": "Inf"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "negative Inf string", + params: map[string]any{"count": "-Inf"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "fractional string", + params: map[string]any{"count": "1.5"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "fractional float64", + params: map[string]any{"count": float64(1.5)}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "NaN float64", + params: map[string]any{"count": math.NaN()}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "Inf float64", + params: map[string]any{"count": math.Inf(1)}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "MaxFloat64", + params: map[string]any{"count": math.MaxFloat64}, + paramName: "count", + expected: 0, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := RequiredInt(tc.params, tc.paramName) + + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + } + }) + } +} +func Test_OptionalIntParam(t *testing.T) { + tests := []struct { + name string + params map[string]any + paramName string + expected int + expectError bool + }{ + { + name: "valid number parameter", + params: map[string]any{"count": float64(42)}, + paramName: "count", + expected: 42, + expectError: false, + }, + { + name: "valid string number parameter", + params: map[string]any{"count": "42"}, + paramName: "count", + expected: 42, + expectError: false, + }, + { + name: "missing parameter", + params: map[string]any{}, + paramName: "count", + expected: 0, + expectError: false, + }, + { + name: "zero value", + params: map[string]any{"count": float64(0)}, + paramName: "count", + expected: 0, + expectError: false, + }, + { + name: "zero string value", + params: map[string]any{"count": "0"}, + paramName: "count", + expected: 0, + expectError: false, + }, + { + name: "wrong type parameter", + params: map[string]any{"count": "not-a-number"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "NaN string", + params: map[string]any{"count": "NaN"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "fractional string", + params: map[string]any{"count": "1.5"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "fractional float64", + params: map[string]any{"count": float64(1.5)}, + paramName: "count", + expected: 0, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := OptionalIntParam(tc.params, tc.paramName) + + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + } + }) + } +} + +func Test_OptionalNumberParamWithDefault(t *testing.T) { + tests := []struct { + name string + params map[string]any + paramName string + defaultVal int + expected int + expectError bool + }{ + { + name: "valid number parameter", + params: map[string]any{"count": float64(42)}, + paramName: "count", + defaultVal: 10, + expected: 42, + expectError: false, + }, + { + name: "valid string number parameter", + params: map[string]any{"count": "42"}, + paramName: "count", + defaultVal: 10, + expected: 42, + expectError: false, + }, + { + name: "missing parameter", + params: map[string]any{}, + paramName: "count", + defaultVal: 10, + expected: 10, + expectError: false, + }, + { + name: "zero value", + params: map[string]any{"count": float64(0)}, + paramName: "count", + defaultVal: 10, + expected: 10, + expectError: false, + }, + { + name: "zero string value uses default", + params: map[string]any{"count": "0"}, + paramName: "count", + defaultVal: 10, + expected: 10, + expectError: false, + }, + { + name: "wrong type parameter", + params: map[string]any{"count": "not-a-number"}, + paramName: "count", + defaultVal: 10, + expected: 0, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := OptionalIntParamWithDefault(tc.params, tc.paramName, tc.defaultVal) + + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + } + }) + } +} + +func Test_OptionalBooleanParam(t *testing.T) { + tests := []struct { + name string + params map[string]any + paramName string + expected bool + expectError bool + }{ + { + name: "true value", + params: map[string]any{"flag": true}, + paramName: "flag", + expected: true, + expectError: false, + }, + { + name: "false value", + params: map[string]any{"flag": false}, + paramName: "flag", + expected: false, + expectError: false, + }, + { + name: "missing parameter", + params: map[string]any{}, + paramName: "flag", + expected: false, + expectError: false, + }, + { + name: "wrong type parameter", + params: map[string]any{"flag": "not-a-boolean"}, + paramName: "flag", + expected: false, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := OptionalParam[bool](tc.params, tc.paramName) + + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + } + }) + } +} + +func TestOptionalStringArrayParam(t *testing.T) { + tests := []struct { + name string + params map[string]any + paramName string + expected []string + expectError bool + }{ + { + name: "parameter not in request", + params: map[string]any{}, + paramName: "flag", + expected: []string{}, + expectError: false, + }, + { + name: "valid any array parameter", + params: map[string]any{ + "flag": []any{"v1", "v2"}, + }, + paramName: "flag", + expected: []string{"v1", "v2"}, + expectError: false, + }, + { + name: "valid string array parameter", + params: map[string]any{ + "flag": []string{"v1", "v2"}, + }, + paramName: "flag", + expected: []string{"v1", "v2"}, + expectError: false, + }, + { + name: "wrong type parameter", + params: map[string]any{ + "flag": 1, + }, + paramName: "flag", + expected: []string{}, + expectError: true, + }, + { + name: "wrong slice type parameter", + params: map[string]any{ + "flag": []any{"foo", 2}, + }, + paramName: "flag", + expected: []string{}, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := OptionalStringArrayParam(tc.params, tc.paramName) + + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + } + }) + } +} + +func TestOptionalPaginationParams(t *testing.T) { + tests := []struct { + name string + params map[string]any + expected PaginationParams + expectError bool + }{ + { + name: "no pagination parameters, default values", + params: map[string]any{}, + expected: PaginationParams{ + Page: 1, + PerPage: 30, + }, + expectError: false, + }, + { + name: "page parameter, default perPage", + params: map[string]any{ + "page": float64(2), + }, + expected: PaginationParams{ + Page: 2, + PerPage: 30, + }, + expectError: false, + }, + { + name: "perPage parameter, default page", + params: map[string]any{ + "perPage": float64(50), + }, + expected: PaginationParams{ + Page: 1, + PerPage: 50, + }, + expectError: false, + }, + { + name: "page and perPage parameters", + params: map[string]any{ + "page": float64(2), + "perPage": float64(50), + }, + expected: PaginationParams{ + Page: 2, + PerPage: 50, + }, + expectError: false, + }, + { + name: "invalid page parameter", + params: map[string]any{ + "page": "not-a-number", + }, + expected: PaginationParams{}, + expectError: true, + }, + { + name: "invalid perPage parameter", + params: map[string]any{ + "perPage": "not-a-number", + }, + expected: PaginationParams{}, + expectError: true, + }, + { + name: "string page and perPage parameters", + params: map[string]any{ + "page": "3", + "perPage": "25", + }, + expected: PaginationParams{ + Page: 3, + PerPage: 25, + }, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := OptionalPaginationParams(tc.params) + + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + } + }) + } +} diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 4fed6364f5..8f24cde7e2 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -6,1023 +6,147 @@ import ( "fmt" "io" "net/http" - "strings" + "strconv" + "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" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) const ( - ProjectUpdateFailedError = "failed to update a project item" - ProjectAddFailedError = "failed to add a project item" - ProjectDeleteFailedError = "failed to delete a project item" - ProjectListFailedError = "failed to list project items" - MaxProjectsPerPage = 50 + ProjectUpdateFailedError = "failed to update a project item" + ProjectAddFailedError = "failed to add a project item" + ProjectDeleteFailedError = "failed to delete a project item" + ProjectListFailedError = "failed to list project items" + ProjectStatusUpdateListFailedError = "failed to list project status updates" + ProjectStatusUpdateGetFailedError = "failed to get project status update" + ProjectStatusUpdateCreateFailedError = "failed to create project status update" + ProjectResolveIDFailedError = "failed to resolve project ID" + MaxProjectsPerPage = 50 ) -// FeatureFlagHoldbackConsolidatedProjects is the feature flag that, when enabled, reverts to -// individual project tools instead of the consolidated project tools. -const FeatureFlagHoldbackConsolidatedProjects = "mcp_holdback_consolidated_projects" - -// Method constants for consolidated project tools -const ( - projectsMethodListProjects = "list_projects" - projectsMethodListProjectFields = "list_project_fields" - projectsMethodListProjectItems = "list_project_items" - projectsMethodGetProject = "get_project" - projectsMethodGetProjectField = "get_project_field" - projectsMethodGetProjectItem = "get_project_item" - projectsMethodAddProjectItem = "add_project_item" - projectsMethodUpdateProjectItem = "update_project_item" - projectsMethodDeleteProjectItem = "delete_project_item" -) - -func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "list_projects", - Description: t("TOOL_LIST_PROJECTS_DESCRIPTION", `List Projects for a user or organization`), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - Description: "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - }, - "query": { - Type: "string", - Description: `Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: "roadmap is:open", "is:open feature planning".`, - }, - "per_page": { - Type: "number", - Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), - }, - "after": { - Type: "string", - Description: "Forward pagination cursor from previous pageInfo.nextCursor.", - }, - "before": { - Type: "string", - Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", - }, - }, - Required: []string{"owner_type", "owner"}, - }, - }, - []scopes.Scope{scopes.ReadProject}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - queryStr, err := OptionalParam[string](args, "query") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - pagination, err := extractPaginationOptionsFromArgs(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - var resp *github.Response - var projects []*github.ProjectV2 - var queryPtr *string - - if queryStr != "" { - queryPtr = &queryStr - } - - minimalProjects := []MinimalProject{} - opts := &github.ListProjectsOptions{ - ListProjectsPaginationOptions: pagination, - Query: queryPtr, - } - - if ownerType == "org" { - projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts) - } else { - projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list projects", - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - for _, project := range projects { - minimalProjects = append(minimalProjects, *convertToMinimalProject(project)) - } - - response := map[string]any{ - "projects": minimalProjects, - "pageInfo": buildPageInfo(resp), - } - - r, err := json.Marshal(response) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "get_project", - Description: t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or org"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "project_number": { - Type: "number", - Description: "The project's number", - }, - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - Description: "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - }, - }, - Required: []string{"project_number", "owner_type", "owner"}, - }, - }, - []scopes.Scope{scopes.ReadProject}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - var resp *github.Response - var project *github.ProjectV2 - - if ownerType == "org" { - project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) - } else { - project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) - } - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project", - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project", resp, body), nil, nil - } - - minimalProject := convertToMinimalProject(project) - r, err := json.Marshal(minimalProject) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "list_project_fields", - Description: t("TOOL_LIST_PROJECT_FIELDS_DESCRIPTION", "List Project fields for a user or org"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - Description: "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - }, - "project_number": { - Type: "number", - Description: "The project's number.", - }, - "per_page": { - Type: "number", - Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), - }, - "after": { - Type: "string", - Description: "Forward pagination cursor from previous pageInfo.nextCursor.", - }, - "before": { - Type: "string", - Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", - }, - }, - Required: []string{"owner_type", "owner", "project_number"}, - }, - }, - []scopes.Scope{scopes.ReadProject}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - pagination, err := extractPaginationOptionsFromArgs(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - var resp *github.Response - var projectFields []*github.ProjectV2Field - - opts := &github.ListProjectsOptions{ - ListProjectsPaginationOptions: pagination, - } - - if ownerType == "org" { - projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts) - } else { - projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list project fields", - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - response := map[string]any{ - "fields": projectFields, - "pageInfo": buildPageInfo(resp), - } - - r, err := json.Marshal(response) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "get_project_field", - Description: t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - Description: "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - }, - "project_number": { - Type: "number", - Description: "The project's number.", - }, - "field_id": { - Type: "number", - Description: "The field's id.", - }, - }, - Required: []string{"owner_type", "owner", "project_number", "field_id"}, - }, - }, - []scopes.Scope{scopes.ReadProject}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - fieldID, err := RequiredBigInt(args, "field_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - var resp *github.Response - var projectField *github.ProjectV2Field - - if ownerType == "org" { - projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID) - } else { - projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project field", - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project field", resp, body), nil, nil - } - r, err := json.Marshal(projectField) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "list_project_items", - Description: t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", `Search project items with advanced filtering`), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - Description: "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - }, - "project_number": { - Type: "number", - Description: "The project's number.", - }, - "query": { - Type: "string", - Description: `Query string for advanced filtering of project items using GitHub's project filtering syntax.`, - }, - "per_page": { - Type: "number", - Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), - }, - "after": { - Type: "string", - Description: "Forward pagination cursor from previous pageInfo.nextCursor.", - }, - "before": { - Type: "string", - Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", - }, - "fields": { - Type: "array", - Description: "Field IDs to include (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned.", - Items: &jsonschema.Schema{ - Type: "string", - }, - }, - }, - Required: []string{"owner_type", "owner", "project_number"}, - }, - }, - []scopes.Scope{scopes.ReadProject}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - queryStr, err := OptionalParam[string](args, "query") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - fields, err := OptionalBigIntArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - pagination, err := extractPaginationOptionsFromArgs(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - var resp *github.Response - var projectItems []*github.ProjectV2Item - var queryPtr *string - - if queryStr != "" { - queryPtr = &queryStr - } - - opts := &github.ListProjectItemsOptions{ - Fields: fields, - ListProjectsOptions: github.ListProjectsOptions{ - ListProjectsPaginationOptions: pagination, - Query: queryPtr, - }, - } - - if ownerType == "org" { - projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts) - } else { - projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectListFailedError, - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - response := map[string]any{ - "items": projectItems, - "pageInfo": buildPageInfo(resp), - } - - r, err := json.Marshal(response) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "get_project_item", - Description: t("TOOL_GET_PROJECT_ITEM_DESCRIPTION", "Get a specific Project item for a user or org"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_PROJECT_ITEM_USER_TITLE", "Get project item"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - Description: "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - }, - "project_number": { - Type: "number", - Description: "The project's number.", - }, - "item_id": { - Type: "number", - Description: "The item's ID.", - }, - "fields": { - Type: "array", - Description: "Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included.", - Items: &jsonschema.Schema{ - Type: "string", - }, - }, - }, - Required: []string{"owner_type", "owner", "project_number", "item_id"}, - }, - }, - []scopes.Scope{scopes.ReadProject}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - itemID, err := RequiredBigInt(args, "item_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - fields, err := OptionalBigIntArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - var resp *github.Response - var projectItem *github.ProjectV2Item - var opts *github.GetProjectItemOptions - - if len(fields) > 0 { - opts = &github.GetProjectItemOptions{ - Fields: fields, - } - } - - if ownerType == "org" { - projectItem, resp, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, itemID, opts) - } else { - projectItem, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, itemID, opts) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project item", - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(projectItem) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "add_project_item", - Description: t("TOOL_ADD_PROJECT_ITEM_DESCRIPTION", "Add a specific Project item for a user or org"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_ADD_PROJECT_ITEM_USER_TITLE", "Add project item"), - ReadOnlyHint: false, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - Description: "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - }, - "project_number": { - Type: "number", - Description: "The project's number.", - }, - "item_type": { - Type: "string", - Description: "The item's type, either issue or pull_request.", - Enum: []any{"issue", "pull_request"}, - }, - "item_id": { - Type: "number", - Description: "The numeric ID of the issue or pull request to add to the project.", - }, - }, - Required: []string{"owner_type", "owner", "project_number", "item_type", "item_id"}, - }, - }, - []scopes.Scope{scopes.Project}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - itemID, err := RequiredBigInt(args, "item_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - itemType, err := RequiredParam[string](args, "item_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if itemType != "issue" && itemType != "pull_request" { - return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - newItem := &github.AddProjectItemOptions{ - ID: itemID, - Type: toNewProjectType(itemType), - } - - var resp *github.Response - var addedItem *github.ProjectV2Item - - if ownerType == "org" { - addedItem, resp, err = client.Projects.AddOrganizationProjectItem(ctx, owner, projectNumber, newItem) - } else { - addedItem, resp, err = client.Projects.AddUserProjectItem(ctx, owner, projectNumber, newItem) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectAddFailedError, - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() +// Method constants for consolidated project tools +const ( + projectsMethodListProjects = "list_projects" + projectsMethodListProjectFields = "list_project_fields" + projectsMethodListProjectItems = "list_project_items" + projectsMethodGetProject = "get_project" + projectsMethodGetProjectField = "get_project_field" + projectsMethodGetProjectItem = "get_project_item" + projectsMethodAddProjectItem = "add_project_item" + projectsMethodUpdateProjectItem = "update_project_item" + projectsMethodDeleteProjectItem = "delete_project_item" + projectsMethodListProjectStatusUpdates = "list_project_status_updates" + projectsMethodGetProjectStatusUpdate = "get_project_status_update" + projectsMethodCreateProjectStatusUpdate = "create_project_status_update" + projectsMethodCreateProject = "create_project" + projectsMethodCreateIterationField = "create_iteration_field" +) - if resp.StatusCode != http.StatusCreated { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectAddFailedError, resp, body), nil, nil - } - r, err := json.Marshal(addedItem) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } +// GraphQL types for ProjectV2 status updates - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool +type statusUpdateNode struct { + ID githubv4.ID + Body *githubv4.String + Status *githubv4.String + CreatedAt githubv4.DateTime + StartDate *githubv4.String + TargetDate *githubv4.String + Creator struct { + Login githubv4.String + } } -func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "update_project_item", - Description: t("TOOL_UPDATE_PROJECT_ITEM_DESCRIPTION", "Update a specific Project item for a user or org"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_UPDATE_PROJECT_ITEM_USER_TITLE", "Update project item"), - ReadOnlyHint: false, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - Description: "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - }, - "project_number": { - Type: "number", - Description: "The project's number.", - }, - "item_id": { - Type: "number", - Description: "The unique identifier of the project item. This is not the issue or pull request ID.", - }, - "updated_field": { - Type: "object", - Description: "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}", - }, - }, - Required: []string{"owner_type", "owner", "project_number", "item_id", "updated_field"}, - }, - }, - []scopes.Scope{scopes.Project}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - itemID, err := RequiredBigInt(args, "item_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - rawUpdatedField, exists := args["updated_field"] - if !exists { - return utils.NewToolResultError("missing required parameter: updated_field"), nil, nil - } - - fieldValue, ok := rawUpdatedField.(map[string]any) - if !ok || fieldValue == nil { - return utils.NewToolResultError("field_value must be an object"), nil, nil - } - - updatePayload, err := buildUpdateProjectItem(fieldValue) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } +type projectVisibility struct { + Public githubv4.Boolean +} - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } +type statusUpdateNodeWithProject struct { + statusUpdateNode + Project projectVisibility +} - var resp *github.Response - var updatedItem *github.ProjectV2Item +type statusUpdateConnection struct { + Nodes []statusUpdateNode + PageInfo PageInfoFragment +} - if ownerType == "org" { - updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload) - } else { - updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload) - } +type statusUpdatesProject struct { + Public githubv4.Boolean + StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"` +} - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectUpdateFailedError, - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() +// statusUpdatesUserQuery is the GraphQL query for listing status updates on a user-owned project. +type statusUpdatesUserQuery struct { + User struct { + ProjectV2 statusUpdatesProject `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` +} - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectUpdateFailedError, resp, body), nil, nil - } - r, err := json.Marshal(updatedItem) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } +// statusUpdatesOrgQuery is the GraphQL query for listing status updates on an org-owned project. +type statusUpdatesOrgQuery struct { + Organization struct { + ProjectV2 statusUpdatesProject `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` +} - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool +// statusUpdateNodeQuery is the GraphQL query for fetching a single status update by node ID. +type statusUpdateNodeQuery struct { + Node struct { + StatusUpdate statusUpdateNodeWithProject `graphql:"... on ProjectV2StatusUpdate"` + } `graphql:"node(id: $id)"` } -func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "delete_project_item", - Description: t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a specific Project item for a user or org"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"), - ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - Description: "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", - }, - "project_number": { - Type: "number", - Description: "The project's number.", - }, - "item_id": { - Type: "number", - Description: "The internal project item ID to delete from the project (not the issue or pull request ID).", - }, - }, - Required: []string{"owner_type", "owner", "project_number", "item_id"}, - }, - }, - []scopes.Scope{scopes.Project}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { +// CreateProjectV2StatusUpdateInput is the input for the createProjectV2StatusUpdate mutation. +// Defined locally because the shurcooL/githubv4 library does not include this type. +type CreateProjectV2StatusUpdateInput struct { + ProjectID githubv4.ID `json:"projectId"` + Body *githubv4.String `json:"body,omitempty"` + Status *githubv4.String `json:"status,omitempty"` + StartDate *githubv4.String `json:"startDate,omitempty"` + TargetDate *githubv4.String `json:"targetDate,omitempty"` + ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"` +} - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - itemID, err := RequiredBigInt(args, "item_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } +// validProjectV2StatusUpdateStatuses is the set of valid status values for the createProjectV2StatusUpdate mutation. +var validProjectV2StatusUpdateStatuses = map[string]bool{ + "INACTIVE": true, + "ON_TRACK": true, + "AT_RISK": true, + "OFF_TRACK": true, + "COMPLETE": true, +} - var resp *github.Response - if ownerType == "org" { - resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID) - } else { - resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID) - } +func convertToMinimalStatusUpdate(node statusUpdateNode) MinimalProjectStatusUpdate { + var creator *MinimalUser + if login := string(node.Creator.Login); login != "" { + creator = &MinimalUser{Login: login} + } - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectDeleteFailedError, - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() + return MinimalProjectStatusUpdate{ + ID: fmt.Sprintf("%v", node.ID), + Body: derefString(node.Body), + Status: derefString(node.Status), + CreatedAt: node.CreatedAt.Time.Format(time.RFC3339), + StartDate: derefString(node.StartDate), + TargetDate: derefString(node.TargetDate), + Creator: creator, + } +} - if resp.StatusCode != http.StatusNoContent { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectDeleteFailedError, resp, body), nil, nil - } - return utils.NewToolResultText("project item successfully deleted"), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool +func derefString(s *githubv4.String) string { + if s == nil { + return "" + } + return string(*s) } // ProjectsList returns the tool and handler for listing GitHub Projects resources. @@ -1049,6 +173,7 @@ Use this tool to list projects for a user or organization, or list project field projectsMethodListProjects, projectsMethodListProjectFields, projectsMethodListProjectItems, + projectsMethodListProjectStatusUpdates, }, }, "owner_type": { @@ -1062,7 +187,7 @@ Use this tool to list projects for a user or organization, or list project field }, "project_number": { Type: "number", - Description: "The project's number. Required for 'list_project_fields' and 'list_project_items' methods.", + Description: "The project's number. Required for 'list_project_fields', 'list_project_items', and 'list_project_status_updates' methods.", }, "query": { Type: "string", @@ -1115,39 +240,57 @@ Use this tool to list projects for a user or organization, or list project field switch method { case projectsMethodListProjects: - return listProjects(ctx, client, args, owner, ownerType) - case projectsMethodListProjectFields: - // Detect owner type if not provided and project_number is available + result, visibilities, payload, err := listProjects(ctx, client, args, owner, ownerType) + result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelProjectList) + return result, payload, err + case projectsMethodListProjectFields, projectsMethodListProjectItems, projectsMethodListProjectStatusUpdates: + // All other methods require project_number and ownerType detection + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } if ownerType == "" { - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } ownerType, err = detectOwnerType(ctx, client, owner, projectNumber) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } } - return listProjectFields(ctx, client, args, owner, ownerType) - case projectsMethodListProjectItems: - // Detect owner type if not provided and project_number is available - if ownerType == "" { - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + + switch method { + case projectsMethodListProjectFields: + result, payload, err := listProjectFields(ctx, client, args, owner, ownerType) + if shouldAttachIFCLabel(ctx, deps, result) { + isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) + if visibilityErr == nil { + result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProject) + } } - ownerType, err = detectOwnerType(ctx, client, owner, projectNumber) + return result, payload, err + case projectsMethodListProjectItems: + result, payload, err := listProjectItems(ctx, client, args, owner, ownerType) + if shouldAttachIFCLabel(ctx, deps, result) { + isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) + if visibilityErr == nil { + result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProjectContent) + } + } + return result, payload, err + case projectsMethodListProjectStatusUpdates: + gqlClient, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + result, isPrivate, payload, err := listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType) + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProjectContent(isPrivate)) + return result, payload, err + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - return listProjectItems(ctx, client, args, owner, ownerType) default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, ) - tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects return tool } @@ -1174,6 +317,7 @@ Use this tool to get details about individual projects, project fields, and proj projectsMethodGetProject, projectsMethodGetProjectField, projectsMethodGetProjectItem, + projectsMethodGetProjectStatusUpdate, }, }, "owner_type": { @@ -1204,8 +348,12 @@ Use this tool to get details about individual projects, project fields, and proj Type: "string", }, }, + "status_update_id": { + Type: "string", + Description: "The node ID of the project status update. Required for 'get_project_status_update' method.", + }, }, - Required: []string{"method", "owner", "project_number"}, + Required: []string{"method"}, }, }, []scopes.Scope{scopes.ReadProject}, @@ -1215,6 +363,21 @@ Use this tool to get details about individual projects, project fields, and proj return utils.NewToolResultError(err.Error()), nil, nil } + // Handle get_project_status_update early — it only needs status_update_id + if method == projectsMethodGetProjectStatusUpdate { + statusUpdateID, err := RequiredParam[string](args, "status_update_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, isPrivate, payload, err := getProjectStatusUpdate(ctx, gqlClient, statusUpdateID) + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProjectContent(isPrivate)) + return result, payload, err + } + owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1245,13 +408,22 @@ Use this tool to get details about individual projects, project fields, and proj switch method { case projectsMethodGetProject: - return getProject(ctx, client, owner, ownerType, projectNumber) + result, isPrivate, payload, err := getProject(ctx, client, owner, ownerType, projectNumber) + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProject(isPrivate)) + return result, payload, err case projectsMethodGetProjectField: fieldID, err := RequiredBigInt(args, "field_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - return getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID) + result, payload, err := getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID) + if shouldAttachIFCLabel(ctx, deps, result) { + isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) + if visibilityErr == nil { + result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProject) + } + } + return result, payload, err case projectsMethodGetProjectItem: itemID, err := RequiredBigInt(args, "item_id") if err != nil { @@ -1261,13 +433,19 @@ Use this tool to get details about individual projects, project fields, and proj if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - return getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) + result, payload, err := getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) + if shouldAttachIFCLabel(ctx, deps, result) { + isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) + if visibilityErr == nil { + result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProjectContent) + } + } + return result, payload, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, ) - tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects return tool } @@ -1277,9 +455,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 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), }, @@ -1293,11 +471,14 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { projectsMethodAddProjectItem, 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": { @@ -1306,7 +487,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", @@ -1337,8 +522,56 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Type: "object", Description: "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.", }, + "body": { + Type: "string", + Description: "The body of the status update (markdown). Used for 'create_project_status_update' method.", + }, + "status": { + Type: "string", + Description: "The status of the project. Used for 'create_project_status_update' method.", + Enum: []any{"INACTIVE", "ON_TRACK", "AT_RISK", "OFF_TRACK", "COMPLETE"}, + }, + "start_date": { + Type: "string", + 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}, @@ -1358,17 +591,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 } @@ -1433,40 +671,54 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(err.Error()), nil, nil } return deleteProjectItem(ctx, client, owner, ownerType, projectNumber, itemID) + case projectsMethodCreateProjectStatusUpdate: + body, err := OptionalParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + status, err := OptionalParam[string](args, "status") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + startDate, err := OptionalParam[string](args, "start_date") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + targetDate, err := OptionalParam[string](args, "target_date") + if err != nil { + 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 } }, ) - tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects return tool } // Helper functions for consolidated projects tools -func listProjects(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { +func listProjects(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, []bool, any, error) { queryStr, err := OptionalParam[string](args, "query") if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), nil, nil, nil } pagination, err := extractPaginationOptionsFromArgs(args) if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), nil, nil, nil } var resp *github.Response var projects []*github.ProjectV2 - var queryPtr *string - - if queryStr != "" { - queryPtr = &queryStr - } minimalProjects := []MinimalProject{} opts := &github.ListProjectsOptions{ ListProjectsPaginationOptions: pagination, - Query: queryPtr, + Query: queryStr, } // If owner_type not provided, fetch from both user and org @@ -1480,7 +732,7 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an "failed to list projects", resp, err, - ), nil, nil + ), nil, nil, nil } default: projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) @@ -1489,7 +741,7 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an "failed to list projects", resp, err, - ), nil, nil + ), nil, nil, nil } } @@ -1510,18 +762,18 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an r, err := json.Marshal(response) if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), projectVisibilities(minimalProjects), nil, nil } - return nil, nil, fmt.Errorf("unexpected state in listProjects") + return nil, nil, nil, fmt.Errorf("unexpected state in listProjects") } // listProjectsFromBothOwnerTypes fetches projects from both user and org endpoints // when owner_type is not specified, combining the results with owner_type labels. -func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, owner string, opts *github.ListProjectsOptions) (*mcp.CallToolResult, any, error) { +func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, owner string, opts *github.ListProjectsOptions) (*mcp.CallToolResult, []bool, any, error) { var minimalProjects []MinimalProject var resp *github.Response @@ -1552,7 +804,7 @@ func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, // If both failed, return error if (userErr != nil || userResp == nil || userResp.StatusCode != http.StatusOK) && (orgErr != nil || orgResp == nil || orgResp.StatusCode != http.StatusOK) { - return utils.NewToolResultError(fmt.Sprintf("failed to list projects for owner '%s': not found as user or organization", owner)), nil, nil + return utils.NewToolResultError(fmt.Sprintf("failed to list projects for owner '%s': not found as user or organization", owner)), nil, nil, nil } response := map[string]any{ @@ -1566,9 +818,21 @@ func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, r, err := json.Marshal(response) if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), projectVisibilities(minimalProjects), nil, nil +} + +func projectVisibilities(projects []MinimalProject) []bool { + visibilities := make([]bool, 0, len(projects)) + for _, project := range projects { + isPrivate := true + if project.Public != nil { + isPrivate = !*project.Public + } + visibilities = append(visibilities, isPrivate) + } + return visibilities } func listProjectFields(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { @@ -1640,17 +904,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, }, } @@ -1669,8 +928,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), } @@ -1682,40 +946,54 @@ func listProjectItems(ctx context.Context, client *github.Client, args map[strin return utils.NewToolResultText(string(r)), nil, nil } -func getProject(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, any, error) { - var resp *github.Response - var project *github.ProjectV2 - var err error - - if ownerType == "org" { - project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) - } else { - project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) - } +func fetchProjectV2(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*github.ProjectV2, *github.Response, error) { + if ownerType == "org" { + return client.Projects.GetOrganizationProject(ctx, owner, projectNumber) + } + return client.Projects.GetUserProject(ctx, owner, projectNumber) +} + +// FetchProjectIsPrivate returns whether a GitHub Project is private. +func FetchProjectIsPrivate(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (bool, error) { + project, resp, err := fetchProjectV2(ctx, client, owner, ownerType, projectNumber) + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return false, err + } + if resp == nil || resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("failed to fetch project visibility") + } + return !project.GetPublic(), nil +} + +func getProject(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, bool, any, error) { + project, resp, err := fetchProjectV2(ctx, client, owner, ownerType, projectNumber) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get project", resp, err, - ), nil, nil + ), false, 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 nil, false, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project", resp, body), nil, nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project", resp, body), false, nil, nil } minimalProject := convertToMinimalProject(project) r, err := json.Marshal(minimalProject) if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), !project.GetPublic(), nil, nil } func getProjectField(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, fieldID int64) (*mcp.CallToolResult, any, error) { @@ -1788,7 +1066,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) } @@ -1827,7 +1105,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) } @@ -1864,6 +1142,43 @@ func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerT return utils.NewToolResultText("project item successfully deleted"), nil, nil } +// resolveProjectNodeID resolves (owner, ownerType, projectNumber) to a project node ID via GraphQL. +func resolveProjectNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) (githubv4.ID, error) { + var projectIDQueryUser struct { + User struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` + } + var projectIDQueryOrg struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + } + + queryVars := map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers + } + + if ownerType == "org" { + err := gqlClient.Query(ctx, &projectIDQueryOrg, queryVars) + if err != nil { + return "", fmt.Errorf("%s: %w", ProjectResolveIDFailedError, err) + } + return projectIDQueryOrg.Organization.ProjectV2.ID, nil + } + + err := gqlClient.Query(ctx, &projectIDQueryUser, queryVars) + if err != nil { + return "", fmt.Errorf("%s: %w", ProjectResolveIDFailedError, err) + } + return projectIDQueryUser.User.ProjectV2.ID, nil +} + // addProjectItem adds an item to a project by resolving the issue/PR number to a node ID func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, itemOwner, itemRepo string, itemNumber int, itemType string) (*mcp.CallToolResult, any, error) { if itemType != "issue" && itemType != "pull_request" { @@ -1886,46 +1201,16 @@ 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)"` } - // First, get the project ID - var projectIDQuery struct { - User struct { - ProjectV2 struct { - ID githubv4.ID - } `graphql:"projectV2(number: $projectNumber)"` - } `graphql:"user(login: $owner)"` - } - var projectIDQueryOrg struct { - Organization struct { - ProjectV2 struct { - ID githubv4.ID - } `graphql:"projectV2(number: $projectNumber)"` - } `graphql:"organization(login: $owner)"` - } - - var projectID githubv4.ID - if ownerType == "org" { - err = gqlClient.Query(ctx, &projectIDQueryOrg, map[string]any{ - "owner": githubv4.String(owner), - "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers - }) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil - } - projectID = projectIDQueryOrg.Organization.ProjectV2.ID - } else { - err = gqlClient.Query(ctx, &projectIDQuery, map[string]any{ - "owner": githubv4.String(owner), - "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers - }) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil - } - projectID = projectIDQuery.User.ProjectV2.ID + // Resolve the project number to a node ID + projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil } // Add the item to the project @@ -1943,6 +1228,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 { @@ -1952,22 +1243,192 @@ func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, owne return utils.NewToolResultText(string(r)), nil, nil } -type pageInfo struct { - HasNextPage bool `json:"hasNextPage"` - HasPreviousPage bool `json:"hasPreviousPage"` - NextCursor string `json:"nextCursor,omitempty"` - PrevCursor string `json:"prevCursor,omitempty"` +// validateDateFormat checks that a date string is in YYYY-MM-DD format. +func validateDateFormat(value, fieldName string) error { + if _, err := time.Parse("2006-01-02", value); err != nil { + return fmt.Errorf("invalid %s %q: must be YYYY-MM-DD format", fieldName, value) + } + return nil } -func toNewProjectType(projType string) string { - switch strings.ToLower(projType) { - case "issue": - return "Issue" - case "pull_request": - return "PullRequest" - default: - return "" +// createProjectStatusUpdate creates a new status update for a project via GraphQL. +func createProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, body, status, startDate, targetDate string) (*mcp.CallToolResult, any, error) { + // Validate inputs + if ownerType != "user" && ownerType != "org" { + return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil + } + if status != "" && !validProjectV2StatusUpdateStatuses[status] { + return utils.NewToolResultError(fmt.Sprintf("invalid status %q: must be one of INACTIVE, ON_TRACK, AT_RISK, OFF_TRACK, COMPLETE", status)), nil, nil + } + if startDate != "" { + if err := validateDateFormat(startDate, "start_date"); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } + if targetDate != "" { + if err := validateDateFormat(targetDate, "target_date"); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } + + // Resolve project number to project node ID + projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Build mutation input + input := CreateProjectV2StatusUpdateInput{ + ProjectID: projectID, + } + + if body != "" { + s := githubv4.String(body) + input.Body = &s + } + if status != "" { + s := githubv4.String(status) + input.Status = &s + } + if startDate != "" { + s := githubv4.String(startDate) + input.StartDate = &s + } + if targetDate != "" { + s := githubv4.String(targetDate) + input.TargetDate = &s + } + + // Execute mutation + var mutation struct { + CreateProjectV2StatusUpdate struct { + StatusUpdate statusUpdateNode + } `graphql:"createProjectV2StatusUpdate(input: $input)"` + } + + err = gqlClient.Mutate(ctx, &mutation, input, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateCreateFailedError, err)), nil, nil + } + + // Convert and return + result := convertToMinimalStatusUpdate(mutation.CreateProjectV2StatusUpdate.StatusUpdate) + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +// listProjectStatusUpdates lists status updates for a project via GraphQL. +func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, bool, any, error) { + if ownerType != "user" && ownerType != "org" { + return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), false, nil, nil + } + + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), false, nil, nil + } + + perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) + if err != nil { + return utils.NewToolResultError(err.Error()), false, nil, nil + } + if perPage > MaxProjectsPerPage { + perPage = MaxProjectsPerPage + } + if perPage < 1 { + perPage = MaxProjectsPerPage + } + + afterCursor, err := OptionalParam[string](args, "after") + if err != nil { + return utils.NewToolResultError(err.Error()), false, nil, nil + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers + "first": githubv4.Int(int32(perPage)), //nolint:gosec // perPage is bounded by MaxProjectsPerPage + } + if afterCursor != "" { + vars["after"] = githubv4.String(afterCursor) + } else { + vars["after"] = (*githubv4.String)(nil) + } + + var nodes []statusUpdateNode + var pi PageInfoFragment + var isPrivate bool + + if ownerType == "org" { + var q statusUpdatesOrgQuery + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), false, nil, nil + } + project := q.Organization.ProjectV2 + nodes = project.StatusUpdates.Nodes + pi = project.StatusUpdates.PageInfo + isPrivate = !bool(project.Public) + } else { + var q statusUpdatesUserQuery + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), false, nil, nil + } + project := q.User.ProjectV2 + nodes = project.StatusUpdates.Nodes + pi = project.StatusUpdates.PageInfo + isPrivate = !bool(project.Public) + } + + updates := make([]MinimalProjectStatusUpdate, 0, len(nodes)) + for _, n := range nodes { + updates = append(updates, convertToMinimalStatusUpdate(n)) + } + + response := map[string]any{ + "statusUpdates": updates, + "pageInfo": map[string]any{ + "hasNextPage": pi.HasNextPage, + "hasPreviousPage": pi.HasPreviousPage, + "nextCursor": string(pi.EndCursor), + "prevCursor": string(pi.StartCursor), + }, + } + + r, err := json.Marshal(response) + if err != nil { + return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err) + } + return utils.NewToolResultText(string(r)), isPrivate, nil, nil +} + +// getProjectStatusUpdate fetches a single status update by its node ID via GraphQL. +func getProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, statusUpdateID string) (*mcp.CallToolResult, bool, any, error) { + var q statusUpdateNodeQuery + vars := map[string]any{ + "id": githubv4.ID(statusUpdateID), + } + + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateGetFailedError, err)), false, nil, nil + } + + if q.Node.StatusUpdate.ID == nil || q.Node.StatusUpdate.ID == "" { + return utils.NewToolResultError(fmt.Sprintf("%s: node is not a ProjectV2StatusUpdate or was not found", ProjectStatusUpdateGetFailedError)), false, nil, nil } + + update := convertToMinimalStatusUpdate(q.Node.StatusUpdate.statusUpdateNode) + isPrivate := !bool(q.Node.StatusUpdate.Project.Public) + + r, err := json.Marshal(update) + if err != nil { + return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err) + } + return utils.NewToolResultText(string(r)), isPrivate, nil, nil } // validateAndConvertToInt64 ensures the value is a number and converts it to int64. @@ -2020,15 +1481,6 @@ func buildUpdateProjectItem(input map[string]any) (*github.UpdateProjectItemOpti return payload, nil } -func buildPageInfo(resp *github.Response) pageInfo { - return pageInfo{ - HasNextPage: resp.After != "", - HasPreviousPage: resp.Before != "", - NextCursor: resp.After, - PrevCursor: resp.Before, - } -} - func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsPaginationOptions, error) { perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) if err != nil { @@ -2049,16 +1501,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 @@ -2112,11 +1557,284 @@ func resolvePullRequestNodeID(ctx context.Context, gqlClient *githubv4.Client, o return query.Repository.PullRequest.ID, nil } -// detectOwnerType attempts to detect the owner type by trying both user and org -// Returns the detected type ("user" or "org") and any error encountered +// 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:"projectV2Field"` + } `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:"projectV2Field"` + } `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 whether the project owner is a user or org. +// It first asks GitHub for the account type, then falls back to project probes +// for older or mocked clients where the account type is unavailable. func detectOwnerType(ctx context.Context, client *github.Client, owner string, projectNumber int) (string, error) { + user, resp, err := client.Users.Get(ctx, owner) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if err == nil && resp != nil && resp.StatusCode == http.StatusOK { + switch user.GetType() { + case "User": + return "user", nil + case "Organization": + return "org", nil + } + } + // Try user first (more common for personal projects) - _, resp, err := client.Projects.GetUserProject(ctx, owner, projectNumber) + _, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) if err == nil && resp.StatusCode == http.StatusOK { _ = resp.Body.Close() return "user", nil diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 24163ef90e..05914975a0 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -9,1529 +9,12 @@ 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/v79/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func Test_ListProjects(t *testing.T) { - serverTool := ListProjects(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_projects", tool.Name) - assert.NotEmpty(t, tool.Description) - schema, ok := tool.InputSchema.(*jsonschema.Schema) - require.True(t, ok, "InputSchema should be a *jsonschema.Schema") - assert.Contains(t, schema.Properties, "owner") - assert.Contains(t, schema.Properties, "owner_type") - assert.Contains(t, schema.Properties, "query") - assert.Contains(t, schema.Properties, "per_page") - assert.ElementsMatch(t, schema.Required, []string{"owner", "owner_type"}) - - // API returns full ProjectV2 objects; we only need minimal fields for decoding. - orgProjects := []map[string]any{{"id": 1, "node_id": "NODE1", "title": "Org Project"}} - userProjects := []map[string]any{{"id": 2, "node_id": "NODE2", "title": "User Project"}} - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedLength int - expectedErrMsg string - }{ - { - name: "success organization", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2: mockResponse(t, http.StatusOK, orgProjects), - }), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - }, - expectError: false, - expectedLength: 1, - }, - { - name: "success user", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetUsersProjectsV2ByUsername: mockResponse(t, http.StatusOK, userProjects), - }), - requestArgs: map[string]interface{}{ - "owner": "octocat", - "owner_type": "user", - }, - expectError: false, - expectedLength: 1, - }, - { - name: "success organization with pagination & query", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2: expectQueryParams(t, map[string]string{ - "per_page": "50", - "q": "roadmap", - }).andThen(mockResponse(t, http.StatusOK, orgProjects)), - }), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "per_page": float64(50), - "query": "roadmap", - }, - expectError: false, - expectedLength: 1, - }, - { - name: "api error", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - }), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - }, - expectError: true, - expectedErrMsg: "failed to list projects", - }, - { - name: "missing owner", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ - "owner_type": "org", - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - return - } - - 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) - projects, ok := response["projects"].([]interface{}) - require.True(t, ok) - assert.Equal(t, tc.expectedLength, len(projects)) - // pageInfo should exist - _, hasPageInfo := response["pageInfo"].(map[string]interface{}) - assert.True(t, hasPageInfo) - }) - } -} - -func Test_GetProject(t *testing.T) { - serverTool := GetProject(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_project", tool.Name) - assert.NotEmpty(t, tool.Description) - schema, ok := tool.InputSchema.(*jsonschema.Schema) - require.True(t, ok, "InputSchema should be a *jsonschema.Schema") - assert.Contains(t, schema.Properties, "project_number") - assert.Contains(t, schema.Properties, "owner") - assert.Contains(t, schema.Properties, "owner_type") - assert.ElementsMatch(t, schema.Required, []string{"project_number", "owner", "owner_type"}) - - project := map[string]any{"id": 123, "title": "Project Title"} - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedErrMsg string - }{ - { - name: "success organization project fetch", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), - }), - requestArgs: map[string]interface{}{ - "project_number": float64(123), - "owner": "octo-org", - "owner_type": "org", - }, - expectError: false, - }, - { - name: "success user project fetch", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusOK, project), - }), - requestArgs: map[string]interface{}{ - "project_number": float64(456), - "owner": "octocat", - "owner_type": "user", - }, - expectError: false, - }, - { - name: "api error", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - }), - requestArgs: map[string]interface{}{ - "project_number": float64(999), - "owner": "octo-org", - "owner_type": "org", - }, - expectError: true, - expectedErrMsg: "failed to get project", - }, - { - name: "missing project_number", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - }, - expectError: true, - }, - { - name: "missing owner", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ - "project_number": float64(123), - "owner_type": "org", - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ - "project_number": float64(123), - "owner": "octo-org", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing project_number" { - assert.Contains(t, text, "missing required parameter: project_number") - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var arr map[string]any - err = json.Unmarshal([]byte(textContent.Text), &arr) - require.NoError(t, err) - }) - } -} - -func Test_ListProjectFields(t *testing.T) { - serverTool := ListProjectFields(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_project_fields", tool.Name) - assert.NotEmpty(t, tool.Description) - schema, ok := tool.InputSchema.(*jsonschema.Schema) - require.True(t, ok, "InputSchema should be a *jsonschema.Schema") - assert.Contains(t, schema.Properties, "owner_type") - assert.Contains(t, schema.Properties, "owner") - assert.Contains(t, schema.Properties, "project_number") - assert.Contains(t, schema.Properties, "per_page") - assert.ElementsMatch(t, schema.Required, []string{"owner_type", "owner", "project_number"}) - - orgFields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}} - userFields := []map[string]any{{"id": 201, "name": "Priority", "data_type": "single_select"}} - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedLength int - expectedErrMsg string - }{ - { - name: "success organization fields", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, orgFields), - }), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - }, - expectedLength: 1, - }, - { - name: "success user fields with per_page override", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetUsersProjectsV2FieldsByUsernameByProject: expectQueryParams(t, map[string]string{ - "per_page": "50", - }).andThen(mockResponse(t, http.StatusOK, userFields)), - }), - requestArgs: map[string]interface{}{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(456), - "per_page": float64(50), - }, - expectedLength: 1, - }, - { - name: "api error", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - }), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(789), - }, - expectError: true, - expectedErrMsg: "failed to list project fields", - }, - { - name: "missing owner", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ - "owner_type": "org", - "project_number": 10, - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "project_number": 10, - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - if tc.name == "missing project_number" { - assert.Contains(t, text, "missing required parameter: project_number") - } - return - } - - 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) - fields, ok := response["fields"].([]interface{}) - require.True(t, ok) - assert.Equal(t, tc.expectedLength, len(fields)) - _, hasPageInfo := response["pageInfo"].(map[string]interface{}) - assert.True(t, hasPageInfo) - }) - } -} - -func Test_GetProjectField(t *testing.T) { - serverTool := GetProjectField(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_project_field", tool.Name) - assert.NotEmpty(t, tool.Description) - schema, ok := tool.InputSchema.(*jsonschema.Schema) - require.True(t, ok, "InputSchema should be a *jsonschema.Schema") - assert.Contains(t, schema.Properties, "owner_type") - assert.Contains(t, schema.Properties, "owner") - assert.Contains(t, schema.Properties, "project_number") - assert.Contains(t, schema.Properties, "field_id") - assert.ElementsMatch(t, schema.Required, []string{"owner_type", "owner", "project_number", "field_id"}) - - orgField := map[string]any{"id": 101, "name": "Status", "dataType": "single_select"} - userField := map[string]any{"id": 202, "name": "Priority", "dataType": "single_select"} - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - expectedID int - }{ - { - name: "success organization field", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusOK, orgField), - }), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "field_id": float64(101), - }, - expectedID: 101, - }, - { - name: "success user field", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetUsersProjectsV2FieldsByUsernameByProjectByFieldID: mockResponse(t, http.StatusOK, userField), - }), - requestArgs: map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(456), - "field_id": float64(202), - }, - expectedID: 202, - }, - { - name: "api error", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - }), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(789), - "field_id": float64(303), - }, - expectError: true, - expectedErrMsg: "failed to get project field", - }, - { - name: "missing owner", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner_type": "org", - "project_number": float64(10), - "field_id": float64(1), - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "project_number": float64(10), - "field_id": float64(1), - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "field_id": float64(1), - }, - expectError: true, - }, - { - name: "missing field_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(10), - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - if tc.name == "missing project_number" { - assert.Contains(t, text, "missing required parameter: project_number") - } - if tc.name == "missing field_id" { - assert.Contains(t, text, "missing required parameter: field_id") - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var field map[string]any - err = json.Unmarshal([]byte(textContent.Text), &field) - require.NoError(t, err) - if tc.expectedID != 0 { - assert.Equal(t, float64(tc.expectedID), field["id"]) - } - }) - } -} - -func Test_ListProjectItems(t *testing.T) { - serverTool := ListProjectItems(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_project_items", tool.Name) - assert.NotEmpty(t, tool.Description) - schema, ok := tool.InputSchema.(*jsonschema.Schema) - require.True(t, ok, "InputSchema should be a *jsonschema.Schema") - assert.Contains(t, schema.Properties, "owner_type") - assert.Contains(t, schema.Properties, "owner") - assert.Contains(t, schema.Properties, "project_number") - assert.Contains(t, schema.Properties, "query") - assert.Contains(t, schema.Properties, "per_page") - assert.Contains(t, schema.Properties, "fields") - assert.ElementsMatch(t, schema.Required, []string{"owner_type", "owner", "project_number"}) - - orgItems := []map[string]any{ - {"id": 301, "content_type": "Issue", "project_node_id": "PR_1", "fields": []map[string]any{ - {"id": 123, "name": "Status", "data_type": "single_select", "value": "value1"}, - {"id": 456, "name": "Priority", "data_type": "single_select", "value": "value2"}, - }}, - } - userItems := []map[string]any{ - {"id": 401, "content_type": "PullRequest", "project_node_id": "PR_2"}, - {"id": 402, "content_type": "DraftIssue", "project_node_id": "PR_3"}, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedLength int - expectedErrMsg string - }{ - { - name: "success organization items", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, orgItems), - }), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - }, - expectedLength: 1, - }, - { - name: "success organization items with fields", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2ItemsByProject: expectQueryParams(t, map[string]string{ - "fields": "123,456,789", - "per_page": "50", - }).andThen(mockResponse(t, http.StatusOK, orgItems)), - }), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "fields": []interface{}{"123", "456", "789"}, - }, - expectedLength: 1, - }, - { - name: "success user items", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetUsersProjectsV2ItemsByUsernameByProject: mockResponse(t, http.StatusOK, userItems), - }), - requestArgs: map[string]interface{}{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(456), - }, - expectedLength: 2, - }, - { - name: "success with pagination and query", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2ItemsByProject: expectQueryParams(t, map[string]string{ - "per_page": "50", - "q": "bug", - }).andThen(mockResponse(t, http.StatusOK, orgItems)), - }), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "per_page": float64(50), - "query": "bug", - }, - expectedLength: 1, - }, - { - name: "api error", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - }), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(789), - }, - expectError: true, - expectedErrMsg: ProjectListFailedError, - }, - { - name: "missing owner", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ - "owner_type": "org", - "project_number": float64(10), - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "project_number": float64(10), - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - if tc.name == "missing project_number" { - assert.Contains(t, text, "missing required parameter: project_number") - } - return - } - - 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) - items, ok := response["items"].([]interface{}) - require.True(t, ok) - assert.Equal(t, tc.expectedLength, len(items)) - _, hasPageInfo := response["pageInfo"].(map[string]interface{}) - assert.True(t, hasPageInfo) - }) - } -} - -func Test_GetProjectItem(t *testing.T) { - serverTool := GetProjectItem(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_project_item", tool.Name) - assert.NotEmpty(t, tool.Description) - schema, ok := tool.InputSchema.(*jsonschema.Schema) - require.True(t, ok, "InputSchema should be a *jsonschema.Schema") - assert.Contains(t, schema.Properties, "owner_type") - assert.Contains(t, schema.Properties, "owner") - assert.Contains(t, schema.Properties, "project_number") - assert.Contains(t, schema.Properties, "item_id") - assert.Contains(t, schema.Properties, "fields") - assert.ElementsMatch(t, schema.Required, []string{"owner_type", "owner", "project_number", "item_id"}) - - orgItem := map[string]any{ - "id": 301, - "content_type": "Issue", - "project_node_id": "PR_1", - "creator": map[string]any{"login": "octocat"}, - } - userItem := map[string]any{ - "id": 501, - "content_type": "PullRequest", - "project_node_id": "PR_2", - "creator": map[string]any{"login": "jane"}, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - expectedID int - }{ - { - name: "success organization item", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, orgItem), - }), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "item_id": float64(301), - }, - expectedID: 301, - }, - { - name: "success organization item with fields", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2ItemsByProjectByItemID: expectQueryParams(t, map[string]string{ - "fields": "123,456", - }).andThen(mockResponse(t, http.StatusOK, orgItem)), - }), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "item_id": float64(301), - "fields": []interface{}{"123", "456"}, - }, - expectedID: 301, - }, - { - name: "success user item", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetUsersProjectsV2ItemsByUsernameByProjectByItemID: mockResponse(t, http.StatusOK, userItem), - }), - requestArgs: map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(456), - "item_id": float64(501), - }, - expectedID: 501, - }, - { - name: "api error", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - }), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(789), - "item_id": float64(999), - }, - expectError: true, - expectedErrMsg: "failed to get project item", - }, - { - name: "missing owner", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner_type": "org", - "project_number": float64(10), - "item_id": float64(1), - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "project_number": float64(10), - "item_id": float64(1), - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "item_id": float64(1), - }, - expectError: true, - }, - { - name: "missing item_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(10), - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - if tc.name == "missing owner" { - assert.Contains(t, text, "missing required parameter: owner") - } - if tc.name == "missing owner_type" { - assert.Contains(t, text, "missing required parameter: owner_type") - } - if tc.name == "missing project_number" { - assert.Contains(t, text, "missing required parameter: project_number") - } - if tc.name == "missing item_id" { - assert.Contains(t, text, "missing required parameter: item_id") - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var item map[string]any - err = json.Unmarshal([]byte(textContent.Text), &item) - require.NoError(t, err) - if tc.expectedID != 0 { - assert.Equal(t, float64(tc.expectedID), item["id"]) - } - }) - } -} - -func Test_AddProjectItem(t *testing.T) { - serverTool := AddProjectItem(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "add_project_item", tool.Name) - assert.NotEmpty(t, tool.Description) - schema, ok := tool.InputSchema.(*jsonschema.Schema) - require.True(t, ok, "InputSchema should be a *jsonschema.Schema") - assert.Contains(t, schema.Properties, "owner_type") - assert.Contains(t, schema.Properties, "owner") - assert.Contains(t, schema.Properties, "project_number") - assert.Contains(t, schema.Properties, "item_type") - assert.Contains(t, schema.Properties, "item_id") - assert.ElementsMatch(t, schema.Required, []string{"owner_type", "owner", "project_number", "item_type", "item_id"}) - - orgItem := map[string]any{ - "id": 601, - "content_type": "Issue", - "creator": map[string]any{ - "login": "octocat", - "id": 1, - "html_url": "https://github.com/octocat", - "avatar_url": "https://avatars.githubusercontent.com/u/1?v=4", - }, - } - - userItem := map[string]any{ - "id": 701, - "content_type": "PullRequest", - "creator": map[string]any{ - "login": "hubot", - "id": 2, - "html_url": "https://github.com/hubot", - "avatar_url": "https://avatars.githubusercontent.com/u/2?v=4", - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - expectedID int - expectedContentType string - expectedCreatorLogin string - }{ - { - name: "success organization issue", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostOrgsProjectsV2ItemsByProject: expectRequestBody(t, map[string]any{ - "type": "Issue", - "id": float64(9876), - }).andThen(mockResponse(t, http.StatusCreated, orgItem)), - }), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(321), - "item_type": "issue", - "item_id": float64(9876), - }, - expectedID: 601, - expectedContentType: "Issue", - expectedCreatorLogin: "octocat", - }, - { - name: "success user pull request", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostUsersProjectsV2ItemsByUsernameByProject: expectRequestBody(t, map[string]any{ - "type": "PullRequest", - "id": float64(7654), - }).andThen(mockResponse(t, http.StatusCreated, userItem)), - }), - requestArgs: map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(222), - "item_type": "pull_request", - "item_id": float64(7654), - }, - expectedID: 701, - expectedContentType: "PullRequest", - expectedCreatorLogin: "hubot", - }, - { - name: "api error", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - }), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(999), - "item_type": "issue", - "item_id": float64(8888), - }, - expectError: true, - expectedErrMsg: ProjectAddFailedError, - }, - { - name: "missing owner", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner_type": "org", - "project_number": float64(1), - "item_type": "Issue", - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "project_number": float64(1), - "item_type": "Issue", - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "item_type": "Issue", - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing item_type", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing item_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_type": "Issue", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(tc.requestArgs) - - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - switch tc.name { - case "missing owner": - assert.Contains(t, text, "missing required parameter: owner") - case "missing owner_type": - assert.Contains(t, text, "missing required parameter: owner_type") - case "missing project_number": - assert.Contains(t, text, "missing required parameter: project_number") - case "missing item_type": - assert.Contains(t, text, "missing required parameter: item_type") - case "missing item_id": - assert.Contains(t, text, "missing required parameter: item_id") - // case "api error": - // assert.Contains(t, text, ProjectAddFailedError) - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var item map[string]any - require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item)) - if tc.expectedID != 0 { - assert.Equal(t, float64(tc.expectedID), item["id"]) - } - if tc.expectedContentType != "" { - assert.Equal(t, tc.expectedContentType, item["content_type"]) - } - if tc.expectedCreatorLogin != "" { - creator, ok := item["creator"].(map[string]any) - require.True(t, ok) - assert.Equal(t, tc.expectedCreatorLogin, creator["login"]) - } - }) - } -} - -func Test_UpdateProjectItem(t *testing.T) { - serverTool := UpdateProjectItem(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "update_project_item", tool.Name) - assert.NotEmpty(t, tool.Description) - schema, ok := tool.InputSchema.(*jsonschema.Schema) - require.True(t, ok, "InputSchema should be a *jsonschema.Schema") - assert.Contains(t, schema.Properties, "owner_type") - assert.Contains(t, schema.Properties, "owner") - assert.Contains(t, schema.Properties, "project_number") - assert.Contains(t, schema.Properties, "item_id") - assert.Contains(t, schema.Properties, "updated_field") - assert.ElementsMatch(t, schema.Required, []string{"owner_type", "owner", "project_number", "item_id", "updated_field"}) - - orgUpdatedItem := map[string]any{ - "id": 801, - "content_type": "Issue", - } - userUpdatedItem := map[string]any{ - "id": 802, - "content_type": "PullRequest", - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - expectedID int - }{ - { - name: "success organization update", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchOrgsProjectsV2ItemsByProjectByItemID: expectRequestBody(t, map[string]any{ - "fields": []any{map[string]any{"id": float64(101), "value": "Done"}}, - }).andThen(mockResponse(t, http.StatusOK, orgUpdatedItem)), - }), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1001), - "item_id": float64(5555), - "updated_field": map[string]any{ - "id": float64(101), - "value": "Done", - }, - }, - expectedID: 801, - }, - { - name: "success user update", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchUsersProjectsV2ItemsByUsernameByProjectByItemID: expectRequestBody(t, map[string]any{ - "fields": []any{map[string]any{"id": float64(202), "value": float64(42)}}, - }).andThen(mockResponse(t, http.StatusOK, userUpdatedItem)), - }), - requestArgs: map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(2002), - "item_id": float64(6666), - "updated_field": map[string]any{ - "id": float64(202), - "value": float64(42), - }, - }, - expectedID: 802, - }, - { - name: "api error", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - }), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(3003), - "item_id": float64(7777), - "updated_field": map[string]any{ - "id": float64(303), - "value": "In Progress", - }, - }, - expectError: true, - expectedErrMsg: "failed to update a project item", - }, - { - name: "missing owner", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(2), - "updated_field": map[string]any{ - "id": float64(1), - "value": "X", - }, - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "project_number": float64(1), - "item_id": float64(2), - "updated_field": map[string]any{ - "id": float64(1), - "value": "X", - }, - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "item_id": float64(2), - "updated_field": map[string]any{ - "id": float64(1), - "value": "X", - }, - }, - expectError: true, - }, - { - name: "missing item_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "updated_field": map[string]any{ - "id": float64(1), - "value": "X", - }, - }, - expectError: true, - }, - { - name: "missing updated_field", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(2), - }, - expectError: true, - }, - { - name: "updated_field not object", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(2), - "updated_field": "not-an-object", - }, - expectError: true, - }, - { - name: "updated_field missing id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(2), - "updated_field": map[string]any{}, - }, - expectError: true, - }, - { - name: "updated_field missing value", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(2), - "updated_field": map[string]any{ - "id": float64(9), - }, - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - switch tc.name { - case "missing owner": - assert.Contains(t, text, "missing required parameter: owner") - case "missing owner_type": - assert.Contains(t, text, "missing required parameter: owner_type") - case "missing project_number": - assert.Contains(t, text, "missing required parameter: project_number") - case "missing item_id": - assert.Contains(t, text, "missing required parameter: item_id") - case "missing updated_field": - assert.Contains(t, text, "missing required parameter: updated_field") - case "updated_field not object": - assert.Contains(t, text, "field_value must be an object") - case "updated_field missing id": - assert.Contains(t, text, "updated_field.id is required") - case "updated_field missing value": - assert.Contains(t, text, "updated_field.value is required") - } - return - } - - require.False(t, result.IsError) - textContent := getTextResult(t, result) - var item map[string]any - require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item)) - if tc.expectedID != 0 { - assert.Equal(t, float64(tc.expectedID), item["id"]) - } - }) - } -} - -func Test_DeleteProjectItem(t *testing.T) { - serverTool := DeleteProjectItem(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "delete_project_item", tool.Name) - assert.NotEmpty(t, tool.Description) - schema, ok := tool.InputSchema.(*jsonschema.Schema) - require.True(t, ok, "InputSchema should be a *jsonschema.Schema") - assert.Contains(t, schema.Properties, "owner_type") - assert.Contains(t, schema.Properties, "owner") - assert.Contains(t, schema.Properties, "project_number") - assert.Contains(t, schema.Properties, "item_id") - assert.ElementsMatch(t, schema.Required, []string{"owner_type", "owner", "project_number", "item_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - expectedText string - }{ - { - name: "success organization delete", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - DeleteOrgsProjectsV2ItemsByProjectByItemID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - }), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(123), - "item_id": float64(555), - }, - expectedText: "project item successfully deleted", - }, - { - name: "success user delete", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - DeleteUsersProjectsV2ItemsByUsernameByProjectByItemID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - }), - requestArgs: map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(456), - "item_id": float64(777), - }, - expectedText: "project item successfully deleted", - }, - { - name: "api error", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - DeleteOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - }), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(321), - "item_id": float64(999), - }, - expectError: true, - expectedErrMsg: ProjectDeleteFailedError, - }, - { - name: "missing owner", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner_type": "org", - "project_number": float64(1), - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing owner_type", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "project_number": float64(1), - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing project_number", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "item_id": float64(10), - }, - expectError: true, - }, - { - name: "missing item_id", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(1), - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - if tc.expectError { - require.True(t, result.IsError) - text := getTextResult(t, result).Text - if tc.expectedErrMsg != "" { - assert.Contains(t, text, tc.expectedErrMsg) - } - switch tc.name { - case "missing owner": - assert.Contains(t, text, "missing required parameter: owner") - case "missing owner_type": - assert.Contains(t, text, "missing required parameter: owner_type") - case "missing project_number": - assert.Contains(t, text, "missing required parameter: project_number") - case "missing item_id": - assert.Contains(t, text, "missing required parameter: item_id") - } - return - } - - require.False(t, result.IsError) - text := getTextResult(t, result).Text - assert.Contains(t, text, tc.expectedText) - }) - } -} - // Tests for consolidated project tools func Test_ProjectsList(t *testing.T) { @@ -1616,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, } @@ -1639,7 +122,7 @@ func Test_ProjectsList_ListProjects(t *testing.T) { var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) - projects, ok := response["projects"].([]interface{}) + projects, ok := response["projects"].([]any) require.True(t, ok) assert.Equal(t, tc.expectedLength, len(projects)) }) @@ -1656,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, } @@ -1676,45 +159,332 @@ func Test_ProjectsList_ListProjectFields(t *testing.T) { var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) - fieldsList, ok := response["fields"].([]interface{}) + fieldsList, ok := response["fields"].([]any) require.True(t, ok) assert.Equal(t, 1, len(fieldsList)) }) t.Run("missing project_number", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_fields", + "owner": "octo-org", + "owner_type": "org", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: project_number") + }) +} + +func 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{verbosePullRequestProjectItemFixture()} + + t.Run("success organization", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items), + }) + + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ - "method": "list_project_fields", - "owner": "octo-org", - "owner_type": "org", + "method": "list_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + itemsList, ok := response["items"].([]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) + }) +} + +func Test_detectOwnerType(t *testing.T) { + t.Run("uses organization account type", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersByUsername: mockResponse(t, http.StatusOK, map[string]any{ + "login": "github", + "type": "Organization", + }), + }) + client := mustNewGHClient(t, mockedClient) + + ownerType, err := detectOwnerType(context.Background(), client, "github", 1) + + require.NoError(t, err) + assert.Equal(t, "org", ownerType) + }) + + t.Run("uses user account type", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersByUsername: mockResponse(t, http.StatusOK, map[string]any{ + "login": "octocat", + "type": "User", + }), + }) + client := mustNewGHClient(t, mockedClient) + + ownerType, err := detectOwnerType(context.Background(), client, "octocat", 1) + + require.NoError(t, err) + assert.Equal(t, "user", ownerType) + }) + + t.Run("falls back to project probes", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusNotFound, nil), + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, map[string]any{"id": 1}), + }) + client := mustNewGHClient(t, mockedClient) + + ownerType, err := detectOwnerType(context.Background(), client, "octo-org", 1) + + require.NoError(t, err) + assert.Equal(t, "org", ownerType) + }) +} + +func Test_ProjectsList_IFC_InsidersMode(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) + + t.Run("list_projects joins returned project visibilities", func(t *testing.T) { + projects := []map[string]any{ + {"id": 1, "node_id": "NODE1", "title": "Public Project", "public": true}, + {"id": 2, "node_id": "NODE2", "title": "Private Project", "public": false}, + } + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2: mockResponse(t, http.StatusOK, projects), + }) + client := mustNewGHClient(t, mockedClient) + deps := BaseDeps{ + Client: client, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_projects", + "owner": "octo-org", + "owner_type": "org", + }) + + 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("list_project_fields uses project metadata label", func(t *testing.T) { + fields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}} + project := map[string]any{"id": 1, "node_id": "NODE1", "title": "Private Project", "public": false} + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, fields), + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), + }) + client := mustNewGHClient(t, mockedClient) + deps := BaseDeps{ + Client: client, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_fields", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) - require.True(t, result.IsError) - textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "missing required parameter: project_number") - }) -} - -func Test_ProjectsList_ListProjectItems(t *testing.T) { - toolDef := ProjectsList(translations.NullTranslationHelper) + require.False(t, result.IsError) - items := []map[string]any{{"id": 1001, "archived_at": nil, "content": map[string]any{"title": "Issue 1"}}} + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "trusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) - t.Run("success organization", func(t *testing.T) { + t.Run("list_project_items uses project content label", func(t *testing.T) { + items := []map[string]any{verbosePullRequestProjectItemFixture()} + project := map[string]any{"id": 1, "node_id": "NODE1", "title": "Private Project", "public": false} mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items), + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), }) - - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ - Client: client, + Client: client, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ @@ -1723,18 +493,66 @@ func Test_ProjectsList_ListProjectItems(t *testing.T) { "owner_type": "org", "project_number": float64(1), }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) require.False(t, result.IsError) - textContent := getTextResult(t, result) - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("list_project_status_updates uses GraphQL project visibility", func(t *testing.T) { + gqlMockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdatesOrgQuery{}, + map[string]any{ + "owner": githubv4.String("octo-org"), + "projectNumber": githubv4.Int(1), + "first": githubv4.Int(50), + "after": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{ + "public": true, + "statusUpdates": map[string]any{ + "nodes": []map[string]any{}, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + }, + }, + }, + }), + ), + ) + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})), + GQLClient: githubv4.NewClient(gqlMockedClient), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_status_updates", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) - itemsList, ok := response["items"].([]interface{}) - require.True(t, ok) - assert.Equal(t, 1, len(itemsList)) + 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"]) }) } @@ -1752,7 +570,7 @@ func Test_ProjectsGet(t *testing.T) { assert.Contains(t, inputSchema.Properties, "project_number") assert.Contains(t, inputSchema.Properties, "field_id") assert.Contains(t, inputSchema.Properties, "item_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "project_number"}) + assert.ElementsMatch(t, inputSchema.Required, []string{"method"}) } func Test_ProjectsGet_GetProject(t *testing.T) { @@ -1765,7 +583,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, } @@ -1790,7 +608,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, } @@ -1810,6 +628,79 @@ func Test_ProjectsGet_GetProject(t *testing.T) { }) } +func Test_ProjectsGet_IFC_InsidersMode(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + t.Run("get_project uses project metadata label", func(t *testing.T) { + project := map[string]any{"id": 123, "node_id": "NODE1", "title": "Private Project", "public": false} + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), + }) + client := mustNewGHClient(t, mockedClient) + deps := BaseDeps{ + Client: client, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "trusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("get_project_status_update uses GraphQL project visibility", func(t *testing.T) { + gqlMockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdateNodeQuery{}, + map[string]any{ + "id": githubv4.ID("SU_abc123"), + }, + githubv4mock.DataResponse(map[string]any{ + "node": map[string]any{ + "id": "SU_abc123", + "body": "On track", + "status": "ON_TRACK", + "createdAt": "2026-01-15T10:00:00Z", + "startDate": "2026-01-01", + "targetDate": "2026-03-01", + "creator": map[string]any{"login": "octocat"}, + "project": map[string]any{"public": true}, + }, + }), + ), + ) + deps := BaseDeps{ + GQLClient: githubv4.NewClient(gqlMockedClient), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_status_update", + "status_update_id": "SU_abc123", + }) + + 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_ProjectsGet_GetProjectField(t *testing.T) { toolDef := ProjectsGet(translations.NullTranslationHelper) @@ -1820,7 +711,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, } @@ -1846,7 +737,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, } @@ -1869,14 +760,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, } @@ -1897,12 +788,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, } @@ -1941,7 +832,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) @@ -2002,7 +893,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)"` }{}, @@ -2014,7 +906,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", }, }, }), @@ -2046,6 +939,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") }) @@ -2099,7 +994,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)"` }{}, @@ -2111,7 +1007,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", }, }, }), @@ -2143,6 +1040,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") }) @@ -2220,14 +1119,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, } @@ -2252,12 +1151,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, } @@ -2288,7 +1187,7 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { }), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -2311,7 +1210,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, } @@ -2330,3 +1229,313 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { assert.Contains(t, textContent.Text, "missing required parameter: item_id") }) } + +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) + + t.Run("success via consolidated tool", func(t *testing.T) { + // REST mock for detectOwnerType (when owner_type is omitted) + restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusOK, map[string]any{"id": 1}), + }) + + // GQL mock for listProjectStatusUpdates + gqlMockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdatesUserQuery{}, + map[string]any{ + "owner": githubv4.String("octocat"), + "projectNumber": githubv4.Int(1), + "first": githubv4.Int(50), + "after": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "projectV2": map[string]any{ + "public": false, + "statusUpdates": map[string]any{ + "nodes": []map[string]any{ + { + "id": "SU_1", + "body": "On track", + "status": "ON_TRACK", + "createdAt": "2026-01-15T10:00:00Z", + "startDate": "2026-01-01", + "targetDate": "2026-03-01", + "creator": map[string]any{"login": "octocat"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + }, + }, + }, + }), + ), + ) + + gqlClient := githubv4.NewClient(gqlMockedClient) + deps := BaseDeps{ + Client: mustNewGHClient(t, restClient), + GQLClient: gqlClient, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_status_updates", + "owner": "octocat", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + updates, ok := response["statusUpdates"].([]any) + require.True(t, ok) + assert.Len(t, updates, 1) + }) +} + +func Test_ProjectsGet_GetProjectStatusUpdate(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + t.Run("success via consolidated tool", func(t *testing.T) { + gqlMockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdateNodeQuery{}, + map[string]any{ + "id": githubv4.ID("SU_abc123"), + }, + githubv4mock.DataResponse(map[string]any{ + "node": map[string]any{ + "id": "SU_abc123", + "body": "On track", + "status": "ON_TRACK", + "createdAt": "2026-01-15T10:00:00Z", + "startDate": "2026-01-01", + "targetDate": "2026-03-01", + "creator": map[string]any{"login": "octocat"}, + "project": map[string]any{"public": false}, + }, + }), + ), + ) + + gqlClient := githubv4.NewClient(gqlMockedClient) + deps := BaseDeps{ + GQLClient: gqlClient, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_status_update", + "owner": "octocat", + "project_number": float64(1), + "status_update_id": "SU_abc123", + }) + 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, "SU_abc123", response["id"]) + assert.Equal(t, "On track", response["body"]) + }) +} + +func Test_ProjectsWrite_CreateProjectStatusUpdate(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + t.Run("success via consolidated tool", func(t *testing.T) { + bodyStr := githubv4.String("Consolidated test") + statusStr := githubv4.String("AT_RISK") + + gqlMockedClient := githubv4mock.NewMockedHTTPClient( + // Mock project ID query for user + githubv4mock.NewQueryMatcher( + struct { + User struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octocat"), + "projectNumber": githubv4.Int(3), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "projectV2": map[string]any{ + "id": "PVT_project3", + }, + }, + }), + ), + // Mock createProjectV2StatusUpdate mutation + githubv4mock.NewMutationMatcher( + struct { + CreateProjectV2StatusUpdate struct { + StatusUpdate statusUpdateNode + } `graphql:"createProjectV2StatusUpdate(input: $input)"` + }{}, + CreateProjectV2StatusUpdateInput{ + ProjectID: githubv4.ID("PVT_project3"), + Body: &bodyStr, + Status: &statusStr, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createProjectV2StatusUpdate": map[string]any{ + "statusUpdate": map[string]any{ + "id": "PVTSU_su003", + "body": "Consolidated test", + "status": "AT_RISK", + "createdAt": "2026-02-09T12:00:00Z", + "creator": map[string]any{"login": "octocat"}, + }, + }, + }), + ), + ) + + gqlClient := githubv4.NewClient(gqlMockedClient) + deps := BaseDeps{ + GQLClient: gqlClient, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_project_status_update", + "owner": "octocat", + "owner_type": "user", + "project_number": float64(3), + "body": "Consolidated test", + "status": "AT_RISK", + }) + 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, "PVTSU_su003", response["id"]) + assert.Equal(t, "Consolidated test", response["body"]) + assert.Equal(t, "AT_RISK", response["status"]) + }) +} diff --git a/pkg/github/projects_v2_test.go b/pkg/github/projects_v2_test.go new file mode 100644 index 0000000000..701e194767 --- /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:"projectV2Field"` + } `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:"projectV2Field"` + } `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:"projectV2Field"` + } `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:"projectV2Field"` + } `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 62952783e7..ef3e9c0839 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -8,14 +8,14 @@ import ( "net/http" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" 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/lockdown" "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/sanitize" "github.com/github/github-mcp-server/pkg/scopes" @@ -34,13 +34,15 @@ func PullRequestRead(t translations.TranslationHelperFunc) inventory.ServerTool Possible options: 1. get - Get details of a specific pull request. 2. get_diff - Get the diff of a pull request. - 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks. + 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. - 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. + 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned. + 6. 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. + 7. 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. + 8. 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. + 9. 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. `, - Enum: []any{"get", "get_diff", "get_status", "get_files", "get_review_comments", "get_reviews", "get_comments"}, + Enum: []any{"get", "get_diff", "get_status", "get_files", "get_commits", "get_review_comments", "get_reviews", "get_comments", "get_check_runs"}, }, "owner": { Type: "string", @@ -58,6 +60,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, @@ -99,19 +108,32 @@ Possible options: return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } + // attachIFC adds the IFC label to a successful tool result when + // IFC labels are enabled. Pull request content (descriptions, + // diffs, comments, reviews) is user-authored and therefore + // untrusted; confidentiality follows repo visibility. If the + // visibility lookup fails the label is omitted rather than + // misclassifying the result. + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelRepoUserContent) + } + switch method { case "get": - result, err := GetPullRequest(ctx, client, deps.GetRepoAccessCache(), owner, repo, pullNumber, deps.GetFlags()) - return result, nil, err + result, err := GetPullRequest(ctx, client, deps, owner, repo, pullNumber) + return attachIFC(result), nil, err case "get_diff": result, err := GetPullRequestDiff(ctx, client, owner, repo, pullNumber) - return result, nil, err + return attachIFC(result), nil, err case "get_status": result, err := GetPullRequestStatus(ctx, client, owner, repo, pullNumber) - return result, nil, err + return attachIFC(result), nil, err case "get_files": result, err := GetPullRequestFiles(ctx, client, owner, repo, pullNumber, pagination) - return result, nil, err + return attachIFC(result), nil, err + case "get_commits": + result, err := GetPullRequestCommits(ctx, client, owner, repo, pullNumber, pagination) + return attachIFC(result), nil, err case "get_review_comments": gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -121,21 +143,30 @@ Possible options: if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - result, err := GetPullRequestReviewComments(ctx, gqlClient, deps.GetRepoAccessCache(), owner, repo, pullNumber, cursorPagination, deps.GetFlags()) - return result, nil, err + result, err := GetPullRequestReviewComments(ctx, gqlClient, deps, owner, repo, pullNumber, cursorPagination) + return attachIFC(result), nil, err case "get_reviews": - result, err := GetPullRequestReviews(ctx, client, deps.GetRepoAccessCache(), owner, repo, pullNumber, deps.GetFlags()) - return result, nil, err + result, err := GetPullRequestReviews(ctx, client, deps, owner, repo, pullNumber, pagination) + return attachIFC(result), nil, err case "get_comments": - result, err := GetIssueComments(ctx, client, deps.GetRepoAccessCache(), owner, repo, pullNumber, pagination, deps.GetFlags()) - return result, nil, err + result, err := GetIssueComments(ctx, client, deps, owner, repo, pullNumber, pagination) + return attachIFC(result), nil, err + case "get_check_runs": + result, err := GetPullRequestCheckRuns(ctx, client, owner, repo, pullNumber, pagination) + return attachIFC(result), nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }) } -func GetPullRequest(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner, repo string, pullNumber int, ff FeatureFlags) (*mcp.CallToolResult, error) { +func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*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) + pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, @@ -181,12 +212,9 @@ func GetPullRequest(ctx context.Context, client *github.Client, cache *lockdown. } } - r, err := json.Marshal(pr) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + minimalPR := convertToMinimalPullRequest(pr) - return utils.NewToolResultText(string(r)), nil + return MarshalledTextResult(minimalPR), nil } func GetPullRequestDiff(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { @@ -265,6 +293,71 @@ func GetPullRequestStatus(ctx context.Context, client *github.Client, owner, rep return utils.NewToolResultText(string(r)), nil } +func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + // First get the PR to get the head SHA + pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get pull request", + resp, + err, + ), nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil + } + + // Get check runs for the head SHA + opts := &github.ListCheckRunsOptions{ + ListOptions: github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + }, + } + + checkRuns, resp, err := client.Checks.ListCheckRunsForRef(ctx, owner, repo, *pr.Head.SHA, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get check runs", + resp, + err, + ), nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get check runs", resp, body), nil + } + + // Convert to minimal check runs to reduce context usage + minimalCheckRuns := make([]MinimalCheckRun, 0, len(checkRuns.CheckRuns)) + for _, checkRun := range checkRuns.CheckRuns { + minimalCheckRuns = append(minimalCheckRuns, convertToMinimalCheckRun(checkRun)) + } + + minimalResult := MinimalCheckRunsResult{ + TotalCount: checkRuns.GetTotal(), + CheckRuns: minimalCheckRuns, + } + + r, err := json.Marshal(minimalResult) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil +} + func GetPullRequestFiles(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { opts := &github.ListOptions{ PerPage: pagination.PerPage, @@ -288,12 +381,37 @@ func GetPullRequestFiles(ctx context.Context, client *github.Client, owner, repo return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request files", resp, body), nil } - r, err := json.Marshal(files) + minimalFiles := convertToMinimalPRFiles(files) + + return MarshalledTextResult(minimalFiles), nil +} + +func GetPullRequestCommits(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + opts := &github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + } + commits, resp, err := client.PullRequests.ListCommits(ctx, owner, repo, pullNumber, opts) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get pull request commits", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request commits", resp, body), nil } - return utils.NewToolResultText(string(r)), nil + minimalCommits := convertToMinimalPullRequestCommits(commits) + + return MarshalledTextResult(minimalCommits), nil } // GraphQL types for review threads query @@ -340,7 +458,13 @@ type pageInfoFragment struct { EndCursor githubv4.String } -func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Client, cache *lockdown.RepoAccessCache, owner, repo string, pullNumber int, pagination CursorPaginationParams, ff FeatureFlags) (*mcp.CallToolResult, error) { +func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination CursorPaginationParams) (*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) + // Convert pagination parameters to GraphQL format gqlParams, err := pagination.ToGraphQLParams() if err != nil { @@ -401,28 +525,20 @@ func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Clien } } - // Build response with review threads and pagination info - response := map[string]any{ - "reviewThreads": query.Repository.PullRequest.ReviewThreads.Nodes, - "pageInfo": map[string]any{ - "hasNextPage": query.Repository.PullRequest.ReviewThreads.PageInfo.HasNextPage, - "hasPreviousPage": query.Repository.PullRequest.ReviewThreads.PageInfo.HasPreviousPage, - "startCursor": string(query.Repository.PullRequest.ReviewThreads.PageInfo.StartCursor), - "endCursor": string(query.Repository.PullRequest.ReviewThreads.PageInfo.EndCursor), - }, - "totalCount": int(query.Repository.PullRequest.ReviewThreads.TotalCount), - } + return MarshalledTextResult(convertToMinimalReviewThreadsResponse(query)), nil +} - r, err := json.Marshal(response) +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 marshal response: %w", err) + return nil, fmt.Errorf("failed to get repo access cache: %w", err) } + ff := deps.GetFlags(ctx) - return utils.NewToolResultText(string(r)), nil -} - -func GetPullRequestReviews(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner, repo string, pullNumber int, ff FeatureFlags) (*mcp.CallToolResult, error) { - 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", @@ -460,55 +576,81 @@ func GetPullRequestReviews(ctx context.Context, client *github.Client, cache *lo } } - r, err := json.Marshal(reviews) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + minimalReviews := make([]MinimalPullRequestReview, 0, len(reviews)) + for _, review := range reviews { + minimalReviews = append(minimalReviews, convertToMinimalPullRequestReview(review)) } - return utils.NewToolResultText(string(r)), nil + return MarshalledTextResult(minimalReviews), nil } -// CreatePullRequest creates a tool to create a new pull request. -func CreatePullRequest(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", - }, - "title": { - Type: "string", - Description: "PR title", - }, - "body": { - Type: "string", - Description: "PR description", - }, - "head": { - Type: "string", - Description: "Branch containing changes", - }, - "base": { - Type: "string", - Description: "Branch to merge into", - }, - "draft": { - Type: "boolean", - Description: "Create as draft PR", - }, - "maintainer_can_modify": { - Type: "boolean", - Description: "Allow maintainer edits", - }, - }, - Required: []string{"owner", "repo", "title", "head", "base"}, +// PullRequestWriteUIResourceURI is the URI for the create_pull_request tool's MCP App UI resource. +const PullRequestWriteUIResourceURI = "ui://github-mcp-server/pr-write" + +// PullRequestEditUIResourceURI is the URI for the update_pull_request tool's MCP App UI resource. +const PullRequestEditUIResourceURI = "ui://github-mcp-server/pr-edit" + +// 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": {}, + "reviewers": {}, + "show_ui": {}, + "_ui_submitted": {}, +} + +var pullRequestUpdateFormParams = map[string]struct{}{ + "owner": {}, + "repo": {}, + "pullNumber": {}, + "title": {}, + "body": {}, + "state": {}, + "draft": {}, + "base": {}, + "maintainer_can_modify": {}, + "reviewers": {}, + "_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 +} +func pullRequestUpdateHasNonFormParams(args map[string]any) bool { + for key, value := range args { + if value == nil { + continue + } + if _, ok := pullRequestUpdateFormParams[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( ToolsetMetadataPullRequests, mcp.Tool{ @@ -518,10 +660,71 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Title: t("TOOL_CREATE_PULL_REQUEST_USER_TITLE", "Open new pull request"), ReadOnlyHint: false, }, - InputSchema: schema, + Meta: mcp.Meta{ + "ui": map[string]any{ + "resourceUri": PullRequestWriteUIResourceURI, + "visibility": []string{"model", "app"}, + }, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "title": { + Type: "string", + Description: "PR title", + }, + "body": { + Type: "string", + Description: "PR description", + }, + "head": { + Type: "string", + Description: "Branch containing changes", + }, + "base": { + Type: "string", + Description: "Branch to merge into", + }, + "draft": { + Type: "boolean", + Description: "Create as draft PR", + }, + "maintainer_can_modify": { + Type: "boolean", + Description: "Allow maintainer edits", + }, + "reviewers": { + Type: "array", + Description: "GitHub usernames or ORG/team-slug team reviewers to request reviews from", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + // show_ui is hidden from clients that do not advertise MCP App + // UI support. The strip happens per-request in + // inventory.ToolsForRegistration; it is present in the static + // schema (and therefore in toolsnaps and the feature-flag / + // insiders docs) so the UI-capable surface is fully + // documented. It is intentionally not in the main README, + // which renders the stripped (non-UI) schema. + "show_ui": { + Type: "boolean", + Description: "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action.", + }, + }, + Required: []string{"owner", "repo", "title", "head", "base"}, + }, }, []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, req *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 @@ -530,18 +733,52 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - title, err := RequiredParam[string](args, "title") + + // 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), + // - the caller explicitly asked to skip the UI (show_ui=false), 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") + showUI, err := OptionalBoolParamWithDefault(args, "show_ui", true) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - head, err := RequiredParam[string](args, "head") + + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !pullRequestWriteHasNonFormParams(args) { + return utils.NewToolResultAwaitingFormSubmission(fmt.Sprintf( + "An interactive form has been shown to the user for creating a new pull request in %s/%s. "+ + "STOP — do not call any other tools, do not respond as if the pull request was created, "+ + "and do not claim the operation succeeded. The pull request has NOT been created yet; "+ + "only the form was rendered. Wait silently for the user to review and click Submit. "+ + "When they do, the real result will be delivered to your context automatically.", + owner, repo, + )), nil, nil + } + + // When creating PR, title/head/base are required + title, err := OptionalParam[string](args, "title") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - base, err := RequiredParam[string](args, "base") + head, err := OptionalParam[string](args, "head") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + base, err := OptionalParam[string](args, "base") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if title == "" { + return utils.NewToolResultError("missing required parameter: title"), nil, nil + } + if head == "" { + return utils.NewToolResultError("missing required parameter: head"), nil, nil + } + if base == "" { + return utils.NewToolResultError("missing required parameter: base"), nil, nil + } body, err := OptionalParam[string](args, "body") if err != nil { @@ -558,6 +795,11 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(err.Error()), nil, nil } + reviewers, err := OptionalStringArrayParam(args, "reviewers") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + newPR := &github.NewPullRequest{ Title: github.Ptr(title), Head: github.Ptr(head), @@ -593,6 +835,36 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create pull request", resp, bodyBytes), nil, nil } + if len(reviewers) > 0 { + userReviewers, teamReviewers := splitPullRequestReviewers(reviewers) + reviewersRequest := github.ReviewersRequest{ + Reviewers: userReviewers, + TeamReviewers: teamReviewers, + } + + _, reviewerResp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pr.GetNumber(), reviewersRequest) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to request reviewers", + reviewerResp, + err, + ), nil, nil + } + defer func() { + if reviewerResp != nil && reviewerResp.Body != nil { + _ = reviewerResp.Body.Close() + } + }() + + if reviewerResp.StatusCode != http.StatusCreated && reviewerResp.StatusCode != http.StatusOK { + bodyBytes, err := io.ReadAll(reviewerResp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to request reviewers", reviewerResp, bodyBytes), nil, nil + } + } + // Return minimal response with just essential information minimalResponse := MinimalResponse{ ID: fmt.Sprintf("%d", pr.GetID()), @@ -652,7 +924,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", }, @@ -661,7 +933,7 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo", "pullNumber"}, } - return NewTool( + st := NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "update_pull_request", @@ -670,10 +942,16 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Title: t("TOOL_UPDATE_PULL_REQUEST_USER_TITLE", "Edit pull request"), ReadOnlyHint: false, }, + Meta: mcp.Meta{ + "ui": map[string]any{ + "resourceUri": PullRequestEditUIResourceURI, + "visibility": []string{"model", "app"}, + }, + }, InputSchema: schema, }, []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, req *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 @@ -687,6 +965,18 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(err.Error()), nil, nil } + uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !pullRequestUpdateHasNonFormParams(args) { + return utils.NewToolResultAwaitingFormSubmission(fmt.Sprintf( + "An interactive form has been shown to the user for editing pull request #%d in %s/%s. "+ + "STOP — do not call any other tools, do not respond as if the pull request was updated, "+ + "and do not claim the operation succeeded. The pull request has NOT been updated yet; "+ + "only the form was rendered. Wait silently for the user to review and click Submit. "+ + "When they do, the real result will be delivered to your context automatically.", + pullNumber, owner, repo, + )), nil, nil + } + _, draftProvided := args["draft"] var draftValue bool if draftProvided { @@ -787,7 +1077,7 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo } `graphql:"repository(owner: $owner, name: $repo)"` } - err = gqlClient.Query(ctx, &prQuery, map[string]interface{}{ + err = gqlClient.Query(ctx, &prQuery, map[string]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "prNum": githubv4.Int(pullNumber), // #nosec G115 - pull request numbers are always small positive integers @@ -844,8 +1134,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) @@ -898,6 +1190,99 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultErrorFromErr("Failed to marshal response", err), nil, nil } + 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. +func AddReplyToPullRequestComment(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", + }, + "pullNumber": { + Type: "number", + Description: "Pull request number", + }, + "commentId": { + Type: "number", + Description: "The ID of the comment to reply to", + }, + "body": { + Type: "string", + Description: "The text of the reply", + }, + }, + Required: []string{"owner", "repo", "pullNumber", "commentId", "body"}, + } + + return NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "add_reply_to_pull_request_comment", + Description: t("TOOL_ADD_REPLY_TO_PULL_REQUEST_COMMENT_DESCRIPTION", "Add a reply to an existing pull request comment. This creates a new comment that is linked as a reply to the specified comment."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ADD_REPLY_TO_PULL_REQUEST_COMMENT_USER_TITLE", "Add reply to pull request comment"), + ReadOnlyHint: false, + }, + 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 + } + pullNumber, err := RequiredInt(args, "pullNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + commentID, err := RequiredInt(args, "commentId") + 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 + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + comment, resp, err := client.PullRequests.CreateCommentInReplyTo(ctx, owner, repo, pullNumber, body, int64(commentID)) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reply to pull request comment", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + bodyBytes, 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 add reply to pull request comment", resp, bodyBytes), nil, nil + } + + r, err := json.Marshal(comment) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil }) } @@ -1036,12 +1421,23 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool } } - r, err := json.Marshal(prs) + minimalPRs := make([]MinimalPullRequest, 0, len(prs)) + for _, pr := range prs { + if pr != nil { + minimalPRs = append(minimalPRs, convertToMinimalPullRequest(pr)) + } + } + + r, err := json.Marshal(minimalPRs) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Pull request titles/bodies are user-authored (untrusted); + // confidentiality follows repo visibility. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoUserContent) + return result, nil, nil }) } @@ -1211,7 +1607,7 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - result, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests") + result, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests", ifcSearchPostProcessOption(ctx, deps)) return result, nil, err }) } @@ -1319,6 +1715,7 @@ type PullRequestReviewWriteParams struct { Body string Event string CommitID *string + ThreadID string } func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.ServerTool { @@ -1331,7 +1728,7 @@ func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.Serv "method": { Type: "string", Description: `The write operation to perform on pull request review.`, - Enum: []any{"create", "submit_pending", "delete_pending"}, + Enum: []any{"create", "submit_pending", "delete_pending", "resolve_thread", "unresolve_thread"}, }, "owner": { Type: "string", @@ -1358,11 +1755,15 @@ func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.Serv Type: "string", Description: "SHA of commit to review", }, + "threadId": { + Type: "string", + Description: "The node ID of the review thread (e.g., PRRT_kwDOxxx). Required for resolve_thread and unresolve_thread methods. Get thread IDs from pull_request_read with method get_review_comments.", + }, }, Required: []string{"method", "owner", "repo", "pullNumber"}, } - return NewTool( + st := NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "pull_request_review_write", @@ -1372,9 +1773,11 @@ Available methods: - 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. - 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. - 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. +- 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. +- 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, @@ -1382,7 +1785,7 @@ Available methods: []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params PullRequestReviewWriteParams - if err := mapstructure.Decode(args, ¶ms); err != nil { + if err := mapstructure.WeakDecode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } @@ -1402,10 +1805,18 @@ Available methods: case "delete_pending": result, err := DeletePendingPullRequestReview(ctx, client, params) return result, nil, err + case "resolve_thread": + result, err := ResolveReviewThread(ctx, client, params.ThreadID, true) + return result, nil, err + case "unresolve_thread": + result, err := ResolveReviewThread(ctx, client, params.ThreadID, false) + return result, nil, err default: 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) { @@ -1502,7 +1913,7 @@ func SubmitPendingPullRequestReview(ctx context.Context, client *githubv4.Client "prNum": githubv4.Int(params.PullNumber), } - if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { + if err := client.Query(ctx, &getLatestReviewForViewerQuery, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get latest review for current user", err, @@ -1587,7 +1998,7 @@ func DeletePendingPullRequestReview(ctx context.Context, client *githubv4.Client "prNum": githubv4.Int(params.PullNumber), } - if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { + if err := client.Query(ctx, &getLatestReviewForViewerQuery, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get latest review for current user", err, @@ -1631,6 +2042,167 @@ func DeletePendingPullRequestReview(ctx context.Context, client *githubv4.Client return utils.NewToolResultText("pending pull request review successfully deleted"), nil } +// ResolveReviewThread resolves or unresolves a PR review thread using GraphQL mutations. +func ResolveReviewThread(ctx context.Context, client *githubv4.Client, threadID string, resolve bool) (*mcp.CallToolResult, error) { + if threadID == "" { + return utils.NewToolResultError("threadId is required for resolve_thread and unresolve_thread methods"), nil + } + + if resolve { + var mutation struct { + ResolveReviewThread struct { + Thread struct { + ID githubv4.ID + IsResolved githubv4.Boolean + } + } `graphql:"resolveReviewThread(input: $input)"` + } + + input := githubv4.ResolveReviewThreadInput{ + ThreadID: githubv4.ID(threadID), + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to resolve review thread", + err, + ), nil + } + + return utils.NewToolResultText("review thread resolved successfully"), nil + } + + // Unresolve + var mutation struct { + UnresolveReviewThread struct { + Thread struct { + ID githubv4.ID + IsResolved githubv4.Boolean + } + } `graphql:"unresolveReviewThread(input: $input)"` + } + + input := githubv4.UnresolveReviewThreadInput{ + ThreadID: githubv4.ID(threadID), + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to unresolve review thread", + err, + ), nil + } + + 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{ @@ -1692,7 +2264,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", @@ -1717,7 +2289,7 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S StartLine *int32 StartSide *string } - if err := mapstructure.Decode(args, ¶ms); err != nil { + if err := mapstructure.WeakDecode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } @@ -1726,188 +2298,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(context.Background(), &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 - }) -} - -// RequestCopilotReview creates a tool to request a Copilot review for a pull request. -// Note that this tool will not work on GHES where this feature is unsupported. In future, we should not expose this -// tool if the configured host does not support it. -func RequestCopilotReview(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", - }, - "pullNumber": { - Type: "number", - Description: "Pull request number", - }, - }, - Required: []string{"owner", "repo", "pullNumber"}, - } - - return NewTool( - ToolsetMetadataPullRequests, - mcp.Tool{ - Name: "request_copilot_review", - Description: t("TOOL_REQUEST_COPILOT_REVIEW_DESCRIPTION", "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer."), - Icons: octicons.Icons("copilot"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_REQUEST_COPILOT_REVIEW_USER_TITLE", "Request Copilot review"), - ReadOnlyHint: false, - }, - 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 - } - - pullNumber, err := RequiredInt(args, "pullNumber") - 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 - } - - _, resp, err := client.PullRequests.RequestReviewers( - ctx, - owner, - repo, - pullNumber, - github.ReviewersRequest{ - // The login name of the copilot reviewer bot - Reviewers: []string{"copilot-pull-request-reviewer[bot]"}, - }, - ) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to request copilot review", - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusCreated { - bodyBytes, 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 request copilot review", resp, bodyBytes), nil, nil - } - - // Return nothing on success, as there's not much value in returning the Pull Request itself - return utils.NewToolResultText(""), 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 d2664479d8..0f372519e5 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/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -55,7 +54,7 @@ func Test_GetPullRequest(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedPR *github.PullRequest expectedErrMsg string @@ -65,7 +64,7 @@ func Test_GetPullRequest(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get", "owner": "owner", "repo": "repo", @@ -82,7 +81,7 @@ func Test_GetPullRequest(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get", "owner": "owner", "repo": "repo", @@ -96,12 +95,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) @@ -127,14 +126,14 @@ func Test_GetPullRequest(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedPR github.PullRequest + // Unmarshal and verify the minimal result + var returnedPR MinimalPullRequest err = json.Unmarshal([]byte(textContent.Text), &returnedPR) require.NoError(t, err) - assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number) - assert.Equal(t, *tc.expectedPR.Title, *returnedPR.Title) - assert.Equal(t, *tc.expectedPR.State, *returnedPR.State) - assert.Equal(t, *tc.expectedPR.HTMLURL, *returnedPR.HTMLURL) + assert.Equal(t, tc.expectedPR.GetNumber(), returnedPR.Number) + assert.Equal(t, tc.expectedPR.GetTitle(), returnedPR.Title) + assert.Equal(t, tc.expectedPR.GetState(), returnedPR.State) + assert.Equal(t, tc.expectedPR.GetHTMLURL(), returnedPR.HTMLURL) }) } } @@ -194,7 +193,7 @@ func Test_UpdatePullRequest(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedPR *github.PullRequest expectedErrMsg string @@ -202,7 +201,7 @@ func Test_UpdatePullRequest(t *testing.T) { { name: "successful PR update (title, body, base, maintainer_can_modify)", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ "title": "Updated Test PR Title", "body": "Updated test PR body.", "base": "develop", @@ -212,7 +211,7 @@ func Test_UpdatePullRequest(t *testing.T) { ), GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "pullNumber": float64(42), @@ -227,14 +226,14 @@ func Test_UpdatePullRequest(t *testing.T) { { name: "successful PR update (state)", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ "state": "closed", }).andThen( mockResponse(t, http.StatusOK, mockClosedPR), ), GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockClosedPR), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "pullNumber": float64(42), @@ -249,11 +248,29 @@ func Test_UpdatePullRequest(t *testing.T) { PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPRWithReviewers), GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPRWithReviewers), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "reviewers": []any{"reviewer1", "reviewer2"}, + }, + 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": []interface{}{"reviewer1", "reviewer2"}, + "reviewers": []any{"reviewer1", "owner/platform"}, }, expectError: false, expectedPR: mockPRWithReviewers, @@ -261,14 +278,14 @@ func Test_UpdatePullRequest(t *testing.T) { { name: "successful PR update (title only)", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ "title": "Updated Test PR Title", }).andThen( mockResponse(t, http.StatusOK, mockUpdatedPR), ), GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "pullNumber": float64(42), @@ -280,7 +297,7 @@ func Test_UpdatePullRequest(t *testing.T) { { name: "no update parameters provided", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), // No API call expected - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "pullNumber": float64(42), @@ -297,7 +314,7 @@ func Test_UpdatePullRequest(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "pullNumber": float64(42), @@ -314,11 +331,11 @@ func Test_UpdatePullRequest(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Invalid reviewers"}`)) }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "pullNumber": float64(42), - "reviewers": []interface{}{"invalid-user"}, + "reviewers": []any{"invalid-user"}, }, expectError: true, expectedErrMsg: "failed to request reviewers", @@ -328,7 +345,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, @@ -386,7 +403,7 @@ func Test_UpdatePullRequest_Draft(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedPR *github.PullRequest expectedErrMsg string @@ -440,7 +457,7 @@ func Test_UpdatePullRequest_Draft(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "pullNumber": float64(42), @@ -498,7 +515,7 @@ func Test_UpdatePullRequest_Draft(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "pullNumber": float64(42), @@ -512,7 +529,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) @@ -591,7 +608,7 @@ func Test_ListPullRequests(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedPRs []*github.PullRequest expectedErrMsg string @@ -609,7 +626,7 @@ func Test_ListPullRequests(t *testing.T) { mockResponse(t, http.StatusOK, mockPRs), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "state": "all", @@ -629,7 +646,7 @@ func Test_ListPullRequests(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "state": "invalid", @@ -642,7 +659,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, @@ -671,16 +688,16 @@ func Test_ListPullRequests(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var returnedPRs []*github.PullRequest + var returnedPRs []MinimalPullRequest err = json.Unmarshal([]byte(textContent.Text), &returnedPRs) require.NoError(t, err) assert.Len(t, returnedPRs, 2) - 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[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[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[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) }) } } @@ -712,7 +729,7 @@ func Test_MergePullRequest(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedMergeResult *github.PullRequestMergeResult expectedErrMsg string @@ -720,7 +737,7 @@ func Test_MergePullRequest(t *testing.T) { { name: "successful merge", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PutReposPullsMergeByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + PutReposPullsMergeByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ "commit_title": "Merge PR #42", "commit_message": "Merging awesome feature", "merge_method": "squash", @@ -728,7 +745,7 @@ func Test_MergePullRequest(t *testing.T) { mockResponse(t, http.StatusOK, mockMergeResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "pullNumber": float64(42), @@ -747,7 +764,7 @@ func Test_MergePullRequest(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Pull request cannot be merged"}`)) }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "pullNumber": float64(42), @@ -760,7 +777,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, @@ -848,7 +865,7 @@ func Test_SearchPullRequests(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedResult *github.IssuesSearchResult expectedErrMsg string @@ -869,7 +886,7 @@ func Test_SearchPullRequests(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "repo:owner/repo is:open", "sort": "created", "order": "desc", @@ -895,7 +912,7 @@ func Test_SearchPullRequests(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "draft:false", "owner": "test-owner", "repo": "test-repo", @@ -919,7 +936,7 @@ func Test_SearchPullRequests(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "feature", "owner": "test-owner", }, @@ -940,7 +957,7 @@ func Test_SearchPullRequests(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "review-required", "repo": "test-repo", }, @@ -952,7 +969,7 @@ func Test_SearchPullRequests(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "is:pr repo:owner/repo is:open", }, expectError: false, @@ -972,7 +989,7 @@ func Test_SearchPullRequests(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "is:pr repo:github/github-mcp-server is:open draft:false", }, expectError: false, @@ -992,7 +1009,7 @@ func Test_SearchPullRequests(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "repo:github/github-mcp-server author:octocat", "owner": "different-owner", "repo": "different-repo", @@ -1014,7 +1031,7 @@ func Test_SearchPullRequests(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", }, expectError: false, @@ -1028,7 +1045,7 @@ func Test_SearchPullRequests(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "invalid:query", }, expectError: true, @@ -1039,7 +1056,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, @@ -1126,7 +1143,7 @@ func Test_GetPullRequestFiles(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedFiles []*github.CommitFile expectedErrMsg string @@ -1141,7 +1158,7 @@ func Test_GetPullRequestFiles(t *testing.T) { mockResponse(t, http.StatusOK, mockFiles), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_files", "owner": "owner", "repo": "repo", @@ -1160,7 +1177,7 @@ func Test_GetPullRequestFiles(t *testing.T) { mockResponse(t, http.StatusOK, mockFiles), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_files", "owner": "owner", "repo": "repo", @@ -1184,7 +1201,7 @@ func Test_GetPullRequestFiles(t *testing.T) { }), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_files", "owner": "owner", "repo": "repo", @@ -1198,11 +1215,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) @@ -1229,20 +1246,199 @@ func Test_GetPullRequestFiles(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var returnedFiles []*github.CommitFile + var returnedFiles []MinimalPRFile err = json.Unmarshal([]byte(textContent.Text), &returnedFiles) require.NoError(t, err) assert.Len(t, returnedFiles, len(tc.expectedFiles)) for i, file := range returnedFiles { - assert.Equal(t, *tc.expectedFiles[i].Filename, *file.Filename) - assert.Equal(t, *tc.expectedFiles[i].Status, *file.Status) - assert.Equal(t, *tc.expectedFiles[i].Additions, *file.Additions) - assert.Equal(t, *tc.expectedFiles[i].Deletions, *file.Deletions) + assert.Equal(t, tc.expectedFiles[i].GetFilename(), file.Filename) + assert.Equal(t, tc.expectedFiles[i].GetStatus(), file.Status) + assert.Equal(t, tc.expectedFiles[i].GetAdditions(), file.Additions) + assert.Equal(t, tc.expectedFiles[i].GetDeletions(), file.Deletions) + } + }) + } +} + +func Test_GetPullRequestCommits(t *testing.T) { + // Verify tool definition once + serverTool := PullRequestRead(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_read", tool.Name) + assert.NotEmpty(t, tool.Description) + schema := tool.InputSchema.(*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, "pullNumber") + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") + assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"}) + + authorDate := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + mockCommits := []*github.RepositoryCommit{ + { + SHA: github.Ptr("abc123def456"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + Commit: &github.Commit{ + Message: github.Ptr("feat: add commit listing"), + Author: &github.CommitAuthor{ + Name: github.Ptr("Test User"), + Email: github.Ptr("test@example.com"), + Date: &github.Timestamp{Time: authorDate}, + }, + Committer: &github.CommitAuthor{ + Name: github.Ptr("Merge Bot"), + Email: github.Ptr("merge@example.com"), + Date: &github.Timestamp{Time: authorDate.Add(30 * time.Minute)}, + }, + }, + Author: &github.User{ + Login: github.Ptr("test-user"), + ID: github.Ptr(int64(12345)), + HTMLURL: github.Ptr("https://github.com/test-user"), + AvatarURL: github.Ptr("https://github.com/test-user.png"), + }, + Committer: &github.User{ + Login: github.Ptr("merge-bot"), + ID: github.Ptr(int64(67890)), + HTMLURL: github.Ptr("https://github.com/merge-bot"), + AvatarURL: github.Ptr("https://github.com/merge-bot.png"), + }, + }, + { + SHA: github.Ptr("def456abc789"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), + Commit: &github.Commit{ + Message: github.Ptr("fix: handle pagination"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedCommits []*github.RepositoryCommit + expectedErrMsg string + }{ + { + name: "successful commits fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsCommitsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), + ), + }), + requestArgs: map[string]any{ + "method": "get_commits", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: false, + expectedCommits: mockCommits, + }, + { + name: "successful commits fetch with pagination", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsCommitsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), + ), + }), + requestArgs: map[string]any{ + "method": "get_commits", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "page": float64(2), + "perPage": float64(10), + }, + expectError: false, + expectedCommits: mockCommits, + }, + { + name: "commits fetch fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsCommitsByOwnerByRepoByPullNumber: 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_commits", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get pull request commits", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + serverTool := PullRequestRead(translations.NullTranslationHelper) + deps := BaseDeps{ + Client: client, + RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), + } + 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) + assert.NotContains(t, textContent.Text, `"committer"`) + assert.NotContains(t, textContent.Text, `"profile_url"`) + + var returnedCommits []MinimalPullRequestCommit + err = json.Unmarshal([]byte(textContent.Text), &returnedCommits) + require.NoError(t, err) + assert.Len(t, returnedCommits, len(tc.expectedCommits)) + for i, commit := range returnedCommits { + assert.Equal(t, tc.expectedCommits[i].GetSHA(), commit.SHA) + assert.Equal(t, tc.expectedCommits[i].GetHTMLURL(), commit.HTMLURL) + assert.Equal(t, tc.expectedCommits[i].GetCommit().GetMessage(), commit.Message) } + + assert.Equal(t, authorDate.Format(time.RFC3339), returnedCommits[0].Author.Date) }) } } +func Test_ConvertToMinimalPullRequestCommitsSkipsNilCommit(t *testing.T) { + commits := convertToMinimalPullRequestCommits([]*github.RepositoryCommit{nil}) + + require.Empty(t, commits) +} + func Test_GetPullRequestStatus(t *testing.T) { // Verify tool definition once serverTool := PullRequestRead(translations.NullTranslationHelper) @@ -1298,7 +1494,7 @@ func Test_GetPullRequestStatus(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedStatus *github.CombinedStatus expectedErrMsg string @@ -1309,7 +1505,7 @@ func Test_GetPullRequestStatus(t *testing.T) { GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), GetReposCommitsStatusByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockStatus), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_status", "owner": "owner", "repo": "repo", @@ -1326,7 +1522,7 @@ func Test_GetPullRequestStatus(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_status", "owner": "owner", "repo": "repo", @@ -1344,7 +1540,7 @@ func Test_GetPullRequestStatus(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_status", "owner": "owner", "repo": "repo", @@ -1358,11 +1554,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) @@ -1404,93 +1600,122 @@ func Test_GetPullRequestStatus(t *testing.T) { } } -func Test_UpdatePullRequestBranch(t *testing.T) { +func Test_GetPullRequestCheckRuns(t *testing.T) { // Verify tool definition once - serverTool := UpdatePullRequestBranch(translations.NullTranslationHelper) + serverTool := PullRequestRead(translations.NullTranslationHelper) tool := serverTool.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "update_pull_request_branch", tool.Name) + assert.Equal(t, "pull_request_read", tool.Name) assert.NotEmpty(t, tool.Description) schema := tool.InputSchema.(*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, "pullNumber") - assert.Contains(t, schema.Properties, "expectedHeadSha") - assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber"}) + assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"}) - // Setup mock update result for success case - mockUpdateResult := &github.PullRequestBranchUpdateResponse{ - Message: github.Ptr("Branch was updated successfully"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/pulls/42"), + // Setup mock PR for successful PR fetch + mockPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Test PR"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("abcd1234"), + Ref: github.Ptr("feature-branch"), + }, + } + + // Setup mock check runs for success case + mockCheckRuns := &github.ListCheckRunsResults{ + Total: github.Ptr(2), + CheckRuns: []*github.CheckRun{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("build"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + HTMLURL: github.Ptr("https://github.com/owner/repo/runs/1"), + }, + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("test"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + HTMLURL: github.Ptr("https://github.com/owner/repo/runs/2"), + }, + }, } tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedUpdateResult *github.PullRequestBranchUpdateResponse - expectedErrMsg string + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedCheckRuns *github.ListCheckRunsResults + expectedErrMsg string }{ { - name: "successful branch update", + name: "successful check runs fetch", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ - "expected_head_sha": "abcd1234", - }).andThen( - mockResponse(t, http.StatusAccepted, mockUpdateResult), - ), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposCommitsCheckRunsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCheckRuns), }), - requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(42), - "expectedHeadSha": "abcd1234", + requestArgs: map[string]any{ + "method": "get_check_runs", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), }, - expectError: false, - expectedUpdateResult: mockUpdateResult, + expectError: false, + expectedCheckRuns: mockCheckRuns, }, { - name: "branch update without expected SHA", + name: "PR fetch fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{}).andThen( - mockResponse(t, http.StatusAccepted, mockUpdateResult), - ), + GetReposPullsByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ + "method": "get_check_runs", "owner": "owner", "repo": "repo", - "pullNumber": float64(42), + "pullNumber": float64(999), }, - expectError: false, - expectedUpdateResult: mockUpdateResult, + expectError: true, + expectedErrMsg: "failed to get pull request", }, { - name: "branch update fails", + name: "check runs fetch fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusConflict) - _, _ = w.Write([]byte(`{"message": "Merge conflict"}`)) + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposCommitsCheckRunsByOwnerByRepoByRef: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ + "method": "get_check_runs", "owner": "owner", "repo": "repo", "pullNumber": float64(42), }, expectError: true, - expectedErrMsg: "failed to update pull request branch", + expectedErrMsg: "failed to get check runs", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) - serverTool := UpdatePullRequestBranch(translations.NullTranslationHelper) + client := mustNewGHClient(t, tc.mockedClient) + serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ - Client: client, + Client: client, + RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), } handler := serverTool.Handler(deps) @@ -1515,30 +1740,161 @@ func Test_UpdatePullRequestBranch(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "is in progress") + // Unmarshal and verify the result (using minimal type) + var returnedCheckRuns MinimalCheckRunsResult + err = json.Unmarshal([]byte(textContent.Text), &returnedCheckRuns) + require.NoError(t, err) + assert.Equal(t, *tc.expectedCheckRuns.Total, returnedCheckRuns.TotalCount) + assert.Len(t, returnedCheckRuns.CheckRuns, len(tc.expectedCheckRuns.CheckRuns)) + for i, checkRun := range returnedCheckRuns.CheckRuns { + assert.Equal(t, *tc.expectedCheckRuns.CheckRuns[i].Name, checkRun.Name) + assert.Equal(t, *tc.expectedCheckRuns.CheckRuns[i].Status, checkRun.Status) + assert.Equal(t, *tc.expectedCheckRuns.CheckRuns[i].Conclusion, checkRun.Conclusion) + } }) } } -func Test_GetPullRequestComments(t *testing.T) { +func Test_UpdatePullRequestBranch(t *testing.T) { // Verify tool definition once - serverTool := PullRequestRead(translations.NullTranslationHelper) + serverTool := UpdatePullRequestBranch(translations.NullTranslationHelper) tool := serverTool.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "pull_request_read", tool.Name) + assert.Equal(t, "update_pull_request_branch", tool.Name) + assert.NotEmpty(t, tool.Description) + schema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "pullNumber") + assert.Contains(t, schema.Properties, "expectedHeadSha") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber"}) + + // Setup mock update result for success case + mockUpdateResult := &github.PullRequestBranchUpdateResponse{ + Message: github.Ptr("Branch was updated successfully"), + URL: github.Ptr("https://api.github.com/repos/owner/repo/pulls/42"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedUpdateResult *github.PullRequestBranchUpdateResponse + expectedErrMsg string + }{ + { + name: "successful branch update", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ + "expected_head_sha": "abcd1234", + }).andThen( + mockResponse(t, http.StatusAccepted, mockUpdateResult), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "expectedHeadSha": "abcd1234", + }, + expectError: false, + expectedUpdateResult: mockUpdateResult, + }, + { + name: "branch update without expected SHA", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{}).andThen( + mockResponse(t, http.StatusAccepted, mockUpdateResult), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: false, + expectedUpdateResult: mockUpdateResult, + }, + { + name: "branch update fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"message": "Merge conflict"}`)) + }), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: true, + expectedErrMsg: "failed to update pull request branch", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := mustNewGHClient(t, tc.mockedClient) + serverTool := UpdatePullRequestBranch(translations.NullTranslationHelper) + deps := BaseDeps{ + Client: client, + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + // Verify results + 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) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + assert.Contains(t, textContent.Text, "is in progress") + }) + } +} + +func Test_GetPullRequestComments(t *testing.T) { + // Verify tool definition once + serverTool := PullRequestRead(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_read", tool.Name) assert.NotEmpty(t, tool.Description) schema := tool.InputSchema.(*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, "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 { name string gqlHTTPClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedErrMsg string lockdownEnabled bool @@ -1549,7 +1905,7 @@ func Test_GetPullRequestComments(t *testing.T) { gqlHTTPClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( reviewThreadsQuery{}, - map[string]interface{}{ + map[string]any{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), "prNum": githubv4.Int(42), @@ -1611,7 +1967,7 @@ func Test_GetPullRequestComments(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_review_comments", "owner": "owner", "repo": "repo", @@ -1619,45 +1975,83 @@ func Test_GetPullRequestComments(t *testing.T) { }, expectError: false, validateResult: func(t *testing.T, textContent string) { - var result map[string]interface{} + var result MinimalReviewThreadsResponse err := json.Unmarshal([]byte(textContent), &result) require.NoError(t, err) - // Validate response structure - assert.Contains(t, result, "reviewThreads") - assert.Contains(t, result, "pageInfo") - assert.Contains(t, result, "totalCount") - // Validate review threads - threads := result["reviewThreads"].([]interface{}) - assert.Len(t, threads, 1) + assert.Len(t, result.ReviewThreads, 1) - thread := threads[0].(map[string]interface{}) - assert.Equal(t, "RT_kwDOA0xdyM4AX1Yz", thread["ID"]) - assert.Equal(t, false, thread["IsResolved"]) - assert.Equal(t, false, thread["IsOutdated"]) - assert.Equal(t, false, thread["IsCollapsed"]) + thread := result.ReviewThreads[0] + assert.Equal(t, false, thread.IsResolved) + assert.Equal(t, false, thread.IsOutdated) + assert.Equal(t, false, thread.IsCollapsed) // Validate comments within thread - comments := thread["Comments"].(map[string]interface{}) - commentNodes := comments["Nodes"].([]interface{}) - assert.Len(t, commentNodes, 2) + assert.Len(t, thread.Comments, 2) // Validate first comment - comment1 := commentNodes[0].(map[string]interface{}) - assert.Equal(t, "PRRC_kwDOA0xdyM4AX1Y0", comment1["ID"]) - assert.Equal(t, "This looks good", comment1["Body"]) - assert.Equal(t, "file1.go", comment1["Path"]) + comment1 := thread.Comments[0] + assert.Equal(t, "This looks good", comment1.Body) + assert.Equal(t, "file1.go", comment1.Path) + assert.Equal(t, "reviewer1", comment1.Author) // Validate pagination info - pageInfo := result["pageInfo"].(map[string]interface{}) - assert.Equal(t, false, pageInfo["hasNextPage"]) - assert.Equal(t, false, pageInfo["hasPreviousPage"]) - assert.Equal(t, "cursor1", pageInfo["startCursor"]) - assert.Equal(t, "cursor2", pageInfo["endCursor"]) + assert.Equal(t, false, result.PageInfo.HasNextPage) + assert.Equal(t, false, result.PageInfo.HasPreviousPage) + assert.Equal(t, "cursor1", result.PageInfo.StartCursor) + assert.Equal(t, "cursor2", result.PageInfo.EndCursor) // Validate total count - assert.Equal(t, float64(1), result["totalCount"]) + 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) }, }, { @@ -1665,7 +2059,7 @@ func Test_GetPullRequestComments(t *testing.T) { gqlHTTPClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( reviewThreadsQuery{}, - map[string]interface{}{ + map[string]any{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), "prNum": githubv4.Int(999), @@ -1676,7 +2070,7 @@ func Test_GetPullRequestComments(t *testing.T) { githubv4mock.ErrorResponse("Could not resolve to a PullRequest with the number of 999."), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_review_comments", "owner": "owner", "repo": "repo", @@ -1690,7 +2084,7 @@ func Test_GetPullRequestComments(t *testing.T) { gqlHTTPClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( reviewThreadsQuery{}, - map[string]interface{}{ + map[string]any{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), "prNum": githubv4.Int(42), @@ -1752,7 +2146,7 @@ func Test_GetPullRequestComments(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_review_comments", "owner": "owner", "repo": "repo", @@ -1761,27 +2155,22 @@ func Test_GetPullRequestComments(t *testing.T) { expectError: false, lockdownEnabled: true, validateResult: func(t *testing.T, textContent string) { - var result map[string]interface{} + var result MinimalReviewThreadsResponse err := json.Unmarshal([]byte(textContent), &result) require.NoError(t, err) // Validate that only maintainer comment is returned - threads := result["reviewThreads"].([]interface{}) - assert.Len(t, threads, 1) + assert.Len(t, result.ReviewThreads, 1) - thread := threads[0].(map[string]interface{}) - comments := thread["Comments"].(map[string]interface{}) + thread := result.ReviewThreads[0] // Should only have 1 comment (maintainer) after filtering - assert.Equal(t, float64(1), comments["TotalCount"]) - - commentNodes := comments["Nodes"].([]interface{}) - assert.Len(t, commentNodes, 1) + assert.Equal(t, 1, thread.TotalCount) + assert.Len(t, thread.Comments, 1) - comment := commentNodes[0].(map[string]interface{}) - author := comment["Author"].(map[string]interface{}) - assert.Equal(t, "maintainer", author["Login"]) - assert.Equal(t, "Maintainer review comment", comment["Body"]) + comment := thread.Comments[0] + assert.Equal(t, "maintainer", comment.Author) + assert.Equal(t, "Maintainer review comment", comment.Body) }, }, } @@ -1797,17 +2186,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, @@ -1888,7 +2280,7 @@ func Test_GetPullRequestReviews(t *testing.T) { name string mockedClient *http.Client gqlHTTPClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedReviews []*github.PullRequestReview expectedErrMsg string @@ -1899,11 +2291,32 @@ func Test_GetPullRequestReviews(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsReviewsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockReviews), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ + "method": "get_reviews", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + 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, @@ -1911,12 +2324,17 @@ func Test_GetPullRequestReviews(t *testing.T) { { 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]interface{}{ + requestArgs: map[string]any{ "method": "get_reviews", "owner": "owner", "repo": "repo", @@ -1943,8 +2361,7 @@ func Test_GetPullRequestReviews(t *testing.T) { }, }), }), - gqlHTTPClient: newRepoAccessHTTPClient(), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_reviews", "owner": "owner", "repo": "repo", @@ -1966,14 +2383,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{ @@ -2005,18 +2423,18 @@ func Test_GetPullRequestReviews(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var returnedReviews []*github.PullRequestReview + var returnedReviews []MinimalPullRequestReview err = json.Unmarshal([]byte(textContent.Text), &returnedReviews) require.NoError(t, err) assert.Len(t, returnedReviews, len(tc.expectedReviews)) for i, review := range returnedReviews { + assert.Equal(t, tc.expectedReviews[i].GetID(), review.ID) + assert.Equal(t, tc.expectedReviews[i].GetState(), review.State) + assert.Equal(t, tc.expectedReviews[i].GetBody(), review.Body) require.NotNil(t, tc.expectedReviews[i].User) require.NotNil(t, review.User) - assert.Equal(t, tc.expectedReviews[i].GetID(), review.GetID()) - assert.Equal(t, tc.expectedReviews[i].GetState(), review.GetState()) - assert.Equal(t, tc.expectedReviews[i].GetBody(), review.GetBody()) - assert.Equal(t, tc.expectedReviews[i].GetUser().GetLogin(), review.GetUser().GetLogin()) - assert.Equal(t, tc.expectedReviews[i].GetHTMLURL(), review.GetHTMLURL()) + assert.Equal(t, tc.expectedReviews[i].GetUser().GetLogin(), review.User.Login) + assert.Equal(t, tc.expectedReviews[i].GetHTMLURL(), review.HTMLURL) } }) } @@ -2066,7 +2484,7 @@ func Test_CreatePullRequest(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedPR *github.PullRequest expectedErrMsg string @@ -2074,7 +2492,7 @@ func Test_CreatePullRequest(t *testing.T) { { name: "successful PR creation", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposPullsByOwnerByRepo: expectRequestBody(t, map[string]interface{}{ + PostReposPullsByOwnerByRepo: expectRequestBody(t, map[string]any{ "title": "Test PR", "body": "This is a test PR", "head": "feature-branch", @@ -2085,7 +2503,7 @@ func Test_CreatePullRequest(t *testing.T) { mockResponse(t, http.StatusCreated, mockPR), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "title": "Test PR", @@ -2101,7 +2519,7 @@ func Test_CreatePullRequest(t *testing.T) { { name: "missing required parameter", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", // missing title, head, base @@ -2117,7 +2535,7 @@ func Test_CreatePullRequest(t *testing.T) { _, _ = w.Write([]byte(`{"message":"Validation failed","errors":[{"resource":"PullRequest","code":"invalid"}]}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "title": "Test PR", @@ -2132,7 +2550,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, @@ -2172,29 +2590,356 @@ func Test_CreatePullRequest(t *testing.T) { } } -func TestCreateAndSubmitPullRequestReview(t *testing.T) { +// 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_MCPAppsFeature_UIGate(t *testing.T) { t.Parallel() - // Verify tool definition once - serverTool := PullRequestReviewWrite(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + mockPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Test PR"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{SHA: github.Ptr("abc"), Ref: github.Ptr("feature")}, + Base: &github.PullRequestBranch{SHA: github.Ptr("def"), Ref: github.Ptr("main")}, + User: &github.User{Login: github.Ptr("testuser")}, + } - assert.Equal(t, "pull_request_review_write", tool.Name) - assert.NotEmpty(t, tool.Description) - schema := tool.InputSchema.(*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, "pullNumber") - assert.Contains(t, schema.Properties, "body") - assert.Contains(t, schema.Properties, "event") - assert.Contains(t, schema.Properties, "commitID") - assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"}) + serverTool := CreatePullRequest(translations.NullTranslationHelper) + + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsByOwnerByRepo: mockResponse(t, http.StatusCreated, mockPR), + })) + + deps := BaseDeps{ + Client: client, + GQLClient: githubv4.NewClient(nil), + featureChecker: featureCheckerFor(MCPAppsFeatureFlag), + } + handler := serverTool.Handler(deps) + + t.Run("UI client without _ui_submitted returns form message", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new pull request") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") + }) + + t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "_ui_submitted": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "tool should return the created PR URL") + }) + + t.Run("non-UI client executes directly without _ui_submitted", func(t *testing.T) { + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + 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", + "unknown_param": "value", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "interactive form has been shown", + "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") + }) + + t.Run("UI client with show_ui=false skips form and executes directly", func(t *testing.T) { + // show_ui=false is the explicit, model-facing way to opt out of the + // form. It must bypass the form even when every other condition would + // route the call there (UI capability, MCP Apps flag on, no + // _ui_submitted, only form params present). + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "show_ui": false, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "interactive form has been shown", + "show_ui=false should skip UI form") + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "show_ui=false call should execute directly and return PR URL") + }) + + t.Run("UI client with show_ui=true returns form message", func(t *testing.T) { + // show_ui=true must still route through the form and must not be + // treated as a non-form parameter that would trigger the safety-net + // bypass. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "show_ui": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "interactive form has been shown", + "show_ui=true should still route through the form") + }) + + t.Run("UI client with show_ui=false and _ui_submitted=true executes directly", func(t *testing.T) { + // _ui_submitted and show_ui=false are two ways to say "execute + // directly". When both are set there must be no conflict — the call + // still executes directly. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "show_ui": false, + "_ui_submitted": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "show_ui=false + _ui_submitted should execute directly") + }) + + t.Run("non-UI client with show_ui=false executes directly (no regression)", func(t *testing.T) { + // show_ui is irrelevant when the client does not support UI; the call + // must execute directly exactly as it does today. + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "show_ui": false, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "non-UI client should execute directly regardless of show_ui") + }) +} + +// Test_UpdatePullRequest_MCPAppsFeature_UIGate verifies the form-routing +// behavior for update_pull_request: UI clients without _ui_submitted get a +// pending-form stub (marked IsError so agents don't claim success), UI clients +// with _ui_submitted execute directly, non-UI clients execute directly, and +// UI clients carrying non-form params bypass the form. +func Test_UpdatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { + t.Parallel() + + mockPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Updated"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{SHA: github.Ptr("abc"), Ref: github.Ptr("feature")}, + Base: &github.PullRequestBranch{SHA: github.Ptr("def"), Ref: github.Ptr("main")}, + User: &github.User{Login: github.Ptr("testuser")}, + } + + serverTool := UpdatePullRequest(translations.NullTranslationHelper) + + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + })) + + deps := BaseDeps{ + Client: client, + GQLClient: githubv4.NewClient(nil), + featureChecker: featureCheckerFor(MCPAppsFeatureFlag), + } + handler := serverTool.Handler(deps) + + t.Run("UI client without _ui_submitted returns form message", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for editing pull request #42") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") + }) + + t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated", + "_ui_submitted": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.False(t, result.IsError, "submitted form should execute successfully: %s", textContent.Text) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "submitted form should return the updated PR URL") + }) + + t.Run("non-UI client executes directly without _ui_submitted", func(t *testing.T) { + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.False(t, result.IsError, "non-UI client should execute directly: %s", textContent.Text) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "non-UI client should return the updated PR URL") + }) + + t.Run("UI client with non-form param skips form and executes directly", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated", + "unknown_param": "value", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "interactive form has been shown", + "non-form param should skip UI form") + }) +} + +func Test_pullRequestWriteHasNonFormParams(t *testing.T) { + t.Parallel() tests := []struct { - name string - mockedClient *http.Client + 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, "reviewers": []any{"octocat"}, "show_ui": true, "_ui_submitted": true}, want: false}, + {name: "show_ui true is a form param", args: map[string]any{"title": "t", "show_ui": true}, want: false}, + {name: "show_ui false is a form param", args: map[string]any{"title": "t", "show_ui": false}, want: false}, + {name: "unknown param present", args: map[string]any{"title": "t", "unknown_param": "value"}, 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)) + }) + } +} + +// Test_createPullRequestSchemaClassification fails when a schema property is +// added without classifying it as either form-resendable +// (pullRequestWriteFormParams) or known-non-form (knownNonForm below). +// Today every property is form-resendable, so knownNonForm is empty. +func Test_createPullRequestSchemaClassification(t *testing.T) { + t.Parallel() + + knownNonForm := map[string]struct{}{} + + tool := CreatePullRequest(translations.NullTranslationHelper) + schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + for prop := range schema.Properties { + _, isForm := pullRequestWriteFormParams[prop] + _, isNonForm := knownNonForm[prop] + + assert.Falsef(t, isForm && isNonForm, + "property %q is classified as both form-resendable and non-form — pick one", prop) + assert.Truef(t, isForm || isNonForm, + "property %q in create_pull_request schema is unclassified — add it to pullRequestWriteFormParams "+ + "(pkg/github/pullrequests.go) if the MCP App form can carry it on submit, otherwise add it to "+ + "the knownNonForm allowlist in this test", prop) + } +} + +func TestCreateAndSubmitPullRequestReview(t *testing.T) { + t.Parallel() + + // Verify tool definition once + serverTool := PullRequestReviewWrite(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_review_write", tool.Name) + assert.NotEmpty(t, tool.Description) + schema := tool.InputSchema.(*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, "pullNumber") + assert.Contains(t, schema.Properties, "body") + assert.Contains(t, schema.Properties, "event") + assert.Contains(t, schema.Properties, "commitID") + assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"}) + + tests := []struct { + name string + mockedClient *http.Client requestArgs map[string]any expectToolError bool expectedToolErrMsg string @@ -2254,6 +2999,61 @@ func TestCreateAndSubmitPullRequestReview(t *testing.T) { }, expectToolError: false, }, + { + name: "successful review creation with string pullNumber", + 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(42), + }, + githubv4mock.DataResponse( + map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_kwDODKw3uc6WYN1T", + }, + }, + }, + ), + ), + githubv4mock.NewMutationMatcher( + struct { + AddPullRequestReview struct { + PullRequestReview struct { + ID githubv4.ID + } + } `graphql:"addPullRequestReview(input: $input)"` + }{}, + githubv4.AddPullRequestReviewInput{ + PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), + Body: githubv4.NewString("This is a test review"), + Event: githubv4mock.Ptr(githubv4.PullRequestReviewEventComment), + CommitOID: githubv4.NewGitObjectID("abcd1234"), + }, + nil, + githubv4mock.DataResponse(map[string]any{}), + ), + ), + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "pullNumber": "42", // Some MCP clients send numeric values as strings + "body": "This is a test review", + "event": "COMMENT", + "commitID": "abcd1234", + }, + expectToolError: false, + }, { name: "failure to get pull request", mockedClient: githubv4mock.NewMockedHTTPClient( @@ -2376,118 +3176,6 @@ func TestCreateAndSubmitPullRequestReview(t *testing.T) { } } -func Test_RequestCopilotReview(t *testing.T) { - t.Parallel() - - serverTool := RequestCopilotReview(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "request_copilot_review", tool.Name) - assert.NotEmpty(t, tool.Description) - schema := tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, schema.Properties, "owner") - assert.Contains(t, schema.Properties, "repo") - assert.Contains(t, schema.Properties, "pullNumber") - assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber"}) - - // Setup mock PR for success case - mockPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - Head: &github.PullRequestBranch{ - SHA: github.Ptr("abcd1234"), - Ref: github.Ptr("feature-branch"), - }, - Base: &github.PullRequestBranch{ - Ref: github.Ptr("main"), - }, - Body: github.Ptr("This is a test PR"), - User: &github.User{ - Login: github.Ptr("testuser"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful request", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: expect(t, expectations{ - path: "/repos/owner/repo/pulls/1/requested_reviewers", - requestBody: map[string]any{ - "reviewers": []any{"copilot-pull-request-reviewer[bot]"}, - }, - }).andThen( - mockResponse(t, http.StatusCreated, mockPR), - ), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(1), - }, - expectError: false, - }, - { - name: "request fails", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(999), - }, - expectError: true, - expectedErrMsg: "failed to request copilot review", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - client := github.NewClient(tc.mockedClient) - serverTool := RequestCopilotReview(translations.NullTranslationHelper) - 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) - assert.NotNil(t, result) - assert.Len(t, result.Content, 1) - - textContent := getTextResult(t, result) - require.Equal(t, "", textContent.Text) - }) - } -} - func TestCreatePendingPullRequestReview(t *testing.T) { t.Parallel() @@ -2769,6 +3457,65 @@ func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) { ), ), }, + { + name: "successful line comment with string pullNumber and line", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": "42", // Some MCP clients send numeric values as strings + "path": "file.go", + "body": "This is a test comment", + "subjectType": "LINE", + "line": "10", // string line number + "side": "RIGHT", + "startLine": "5", // string startLine + "startSide": "RIGHT", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + viewerQuery("williammartin"), + getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ + author: "williammartin", + owner: "owner", + repo: "repo", + prNum: 42, + + reviews: []getLatestPendingReviewQueryReview{ + { + id: "PR_kwDODKw3uc6WYN1T", + state: "PENDING", + url: "https://github.com/owner/repo/pull/42", + }, + }, + }), + githubv4mock.NewMutationMatcher( + struct { + AddPullRequestReviewThread struct { + Thread struct { + ID githubv4.String + } + } `graphql:"addPullRequestReviewThread(input: $input)"` + }{}, + githubv4.AddPullRequestReviewThreadInput{ + Path: githubv4.String("file.go"), + Body: githubv4.String("This is a test comment"), + SubjectType: githubv4mock.Ptr(githubv4.PullRequestReviewThreadSubjectTypeLine), + Line: githubv4.NewInt(10), + Side: githubv4mock.Ptr(githubv4.DiffSideRight), + StartLine: githubv4.NewInt(5), + StartSide: githubv4mock.Ptr(githubv4.DiffSideRight), + PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "addPullRequestReviewThread": map[string]any{ + "thread": map[string]any{ + "id": "MDEyOlB1bGxSZXF1ZXN0UmV2aWV3VGhyZWFkMTIzNDU2", + }, + }, + }), + ), + ), + }, { name: "thread ID is nil - invalid line number", requestArgs: map[string]any{ @@ -3126,11 +3873,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) @@ -3227,3 +3974,362 @@ func getLatestPendingReviewQuery(p getLatestPendingReviewQueryParams) githubv4mo ), ) } + +func TestAddReplyToPullRequestComment(t *testing.T) { + t.Parallel() + + // Verify tool definition once + serverTool := AddReplyToPullRequestComment(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "add_reply_to_pull_request_comment", tool.Name) + assert.NotEmpty(t, tool.Description) + schema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "pullNumber") + assert.Contains(t, schema.Properties, "commentId") + assert.Contains(t, schema.Properties, "body") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber", "commentId", "body"}) + + // Setup mock reply comment for success case + mockReplyComment := &github.PullRequestComment{ + ID: github.Ptr(int64(456)), + Body: github.Ptr("This is a reply to the comment"), + InReplyTo: github.Ptr(int64(123)), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#discussion_r456"), + User: &github.User{ + Login: github.Ptr("responder"), + }, + CreatedAt: &github.Timestamp{Time: time.Now()}, + UpdatedAt: &github.Timestamp{Time: time.Now()}, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful reply to pull request comment", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "commentId": float64(123), + "body": "This is a reply to the comment", + }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsCommentsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + responseData, _ := json.Marshal(mockReplyComment) + _, _ = w.Write(responseData) + }, + }), + }, + { + name: "missing required parameter owner", + requestArgs: map[string]any{ + "repo": "repo", + "pullNumber": float64(42), + "commentId": float64(123), + "body": "This is a reply to the comment", + }, + expectToolError: true, + expectedToolErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter repo", + requestArgs: map[string]any{ + "owner": "owner", + "pullNumber": float64(42), + "commentId": float64(123), + "body": "This is a reply to the comment", + }, + expectToolError: true, + expectedToolErrMsg: "missing required parameter: repo", + }, + { + name: "missing required parameter pullNumber", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "commentId": float64(123), + "body": "This is a reply to the comment", + }, + expectToolError: true, + expectedToolErrMsg: "missing required parameter: pullNumber", + }, + { + name: "missing required parameter commentId", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "body": "This is a reply to the comment", + }, + expectToolError: true, + expectedToolErrMsg: "missing required parameter: commentId", + }, + { + name: "missing required parameter body", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "commentId": float64(123), + }, + expectToolError: true, + expectedToolErrMsg: "missing required parameter: body", + }, + { + name: "API error when adding reply", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsCommentsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "commentId": float64(123), + "body": "This is a reply to the comment", + }, + expectToolError: true, + expectedToolErrMsg: "failed to add reply to pull request comment", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Setup client with mock + client := mustNewGHClient(t, tc.mockedClient) + serverTool := AddReplyToPullRequestComment(translations.NullTranslationHelper) + deps := BaseDeps{ + Client: client, + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectToolError { + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedToolErrMsg) + return + } + + // Parse the result and verify it's not an error + require.False(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "This is a reply to the comment") + }) + } +} + +func TestResolveReviewThread(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + expectedResult string + }{ + { + name: "successful resolve thread", + requestArgs: map[string]any{ + "method": "resolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "threadId": "PRRT_kwDOTest123", + }, + 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_kwDOTest123"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "resolveReviewThread": map[string]any{ + "thread": map[string]any{ + "id": "PRRT_kwDOTest123", + "isResolved": true, + }, + }, + }), + ), + ), + expectedResult: "review thread resolved successfully", + }, + { + name: "successful unresolve thread", + requestArgs: map[string]any{ + "method": "unresolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "threadId": "PRRT_kwDOTest123", + }, + 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_kwDOTest123"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "unresolveReviewThread": map[string]any{ + "thread": map[string]any{ + "id": "PRRT_kwDOTest123", + "isResolved": false, + }, + }, + }), + ), + ), + expectedResult: "review thread unresolved successfully", + }, + { + name: "empty threadId for resolve", + requestArgs: map[string]any{ + "method": "resolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "threadId": "", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "threadId is required", + }, + { + name: "empty threadId for unresolve", + requestArgs: map[string]any{ + "method": "unresolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "threadId": "", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "threadId is required", + }, + { + name: "omitted threadId for resolve", + requestArgs: map[string]any{ + "method": "resolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "threadId is required", + }, + { + name: "omitted threadId for unresolve", + requestArgs: map[string]any{ + "method": "unresolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "threadId is required", + }, + { + name: "thread not found", + requestArgs: map[string]any{ + "method": "resolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "threadId": "PRRT_invalid", + }, + 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_invalid"), + }, + nil, + githubv4mock.ErrorResponse("Could not resolve to a PullRequestReviewThread with the id of 'PRRT_invalid'"), + ), + ), + expectToolError: true, + expectedToolErrMsg: "Could not resolve to a PullRequestReviewThread", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Setup client with mock + client := githubv4.NewClient(tc.mockedClient) + serverTool := PullRequestReviewWrite(translations.NullTranslationHelper) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + if tc.expectToolError { + require.True(t, result.IsError) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + require.False(t, result.IsError) + assert.Equal(t, tc.expectedResult, textContent.Text) + }) + } +} diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index f6203f39fc..949a180081 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -2,22 +2,26 @@ package github import ( "context" + "encoding/base64" "encoding/json" "fmt" "io" "net/http" - "net/url" + "slices" + "strconv" "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/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" ) func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { @@ -45,10 +49,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 +73,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,14 +114,20 @@ 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 { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Commit content is reachable from the repo's history; in public + // repos anyone can land it via a PR (untrusted), in private repos + // only collaborators can (trusted). Confidentiality follows repo + // visibility. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents) + return result, nil, nil }, ) } @@ -147,6 +162,18 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { Type: "string", Description: "Author username or email address to filter commits by", }, + "path": { + Type: "string", + Description: "Only commits containing this file path will be returned", + }, + "since": { + Type: "string", + Description: "Only commits after this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", + }, + "until": { + Type: "string", + Description: "Only commits before this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", + }, }, Required: []string{"owner", "repo"}, }), @@ -169,6 +196,18 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + path, err := OptionalParam[string](args, "path") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + sinceStr, err := OptionalParam[string](args, "since") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + untilStr, err := OptionalParam[string](args, "until") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } pagination, err := OptionalPaginationParams(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -180,12 +219,27 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { } opts := &github.CommitsListOptions{ SHA: sha, + Path: path, Author: author, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: perPage, }, } + if sinceStr != "" { + sinceTime, err := parseISOTimestamp(sinceStr) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid since timestamp: %s", err)), nil, nil + } + opts.Since = sinceTime + } + if untilStr != "" { + untilTime, err := parseISOTimestamp(untilStr) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid until timestamp: %s", err)), nil, nil + } + opts.Until = untilTime + } client, err := deps.GetClient(ctx) if err != nil { @@ -212,7 +266,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) @@ -220,7 +274,12 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Commit content is reachable from the repo's history; integrity + // follows the same public-untrusted / private-trusted rule as file + // contents. Confidentiality follows repo visibility. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents) + return result, nil, nil }, ) } @@ -307,7 +366,12 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Branches are structural repo metadata that only collaborators + // with push access can create, so integrity is trusted. + // Confidentiality follows repo visibility. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) + return result, nil, nil }, ) } @@ -322,9 +386,9 @@ func CreateOrUpdateFile(t translations.TranslationHelperFunc) inventory.ServerTo If updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations. In order to obtain the SHA of original file version before updating, use the following git command: -git ls-tree HEAD +git rev-parse : -If the SHA is not provided, the tool will attempt to acquire it by fetching the current file contents from the repository, which may lead to rewriting latest committed changes if the file has changed since last retrieval. +SHA MUST be provided for existing file updates. `), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_CREATE_OR_UPDATE_FILE_USER_TITLE", "Create or update file"), @@ -359,7 +423,7 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the }, "sha": { Type: "string", - Description: "The blob SHA of the file being replaced.", + Description: "The blob SHA of the file being replaced. Required if the file already exists.", }, }, Required: []string{"owner", "repo", "path", "content", "message", "branch"}, @@ -419,55 +483,68 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the path = strings.TrimPrefix(path, "/") - // SHA validation using conditional HEAD request (efficient - no body transfer) - var previousSHA string - contentURL := fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, url.PathEscape(path)) - if branch != "" { - contentURL += "?ref=" + url.QueryEscape(branch) - } + // SHA validation using Contents API to fetch current file metadata (blob SHA) + getOpts := &github.RepositoryContentGetOptions{Ref: branch} if sha != "" { // User provided SHA - validate it's still current - req, err := client.NewRequest("HEAD", contentURL, nil) - if err == nil { - req.Header.Set("If-None-Match", fmt.Sprintf(`"%s"`, sha)) - resp, _ := client.Do(ctx, req, nil) - if resp != nil { - defer resp.Body.Close() - - switch resp.StatusCode { - case http.StatusNotModified: - // SHA matches current - proceed - opts.SHA = github.Ptr(sha) - case http.StatusOK: - // SHA is stale - reject with current SHA so user can check diff - currentSHA := strings.Trim(resp.Header.Get("ETag"), `"`) - return utils.NewToolResultError(fmt.Sprintf( - "SHA mismatch: provided SHA %s is stale. Current file SHA is %s. "+ - "Use get_file_contents or compare commits to review changes before updating.", - sha, currentSHA)), nil, nil - case http.StatusNotFound: - // File doesn't exist - this is a create, ignore provided SHA - } + existingFile, dirContent, respCheck, getErr := client.Repositories.GetContents(ctx, owner, repo, path, getOpts) + if respCheck != nil { + _ = respCheck.Body.Close() + } + switch { + case getErr != nil: + // 404 means file doesn't exist - proceed (new file creation) + // Any other error (403, 500, network) should be surfaced + if respCheck == nil || respCheck.StatusCode != http.StatusNotFound { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to verify file SHA", + respCheck, + getErr, + ), nil, nil + } + case dirContent != nil: + return utils.NewToolResultError(fmt.Sprintf( + "Path %s is a directory, not a file. This tool only works with files.", + path)), nil, nil + case existingFile != nil: + currentSHA := existingFile.GetSHA() + if currentSHA != sha { + return utils.NewToolResultError(fmt.Sprintf( + "SHA mismatch: provided SHA %s is stale. Current file SHA is %s. "+ + "Pull the latest changes and use git rev-parse %s:%s to get the current SHA.", + sha, currentSHA, branch, path)), nil, nil } } } else { - // No SHA provided - check if file exists to warn about blind update - req, err := client.NewRequest("HEAD", contentURL, nil) - if err == nil { - resp, _ := client.Do(ctx, req, nil) - if resp != nil { - defer resp.Body.Close() - if resp.StatusCode == http.StatusOK { - previousSHA = strings.Trim(resp.Header.Get("ETag"), `"`) - } - // 404 = new file, no previous SHA needed + // No SHA provided - check if file already exists + existingFile, dirContent, respCheck, getErr := client.Repositories.GetContents(ctx, owner, repo, path, getOpts) + if respCheck != nil { + _ = respCheck.Body.Close() + } + switch { + case getErr != nil: + // 404 means file doesn't exist - proceed with creation + // Any other error (403, 500, network) should be surfaced + if respCheck == nil || respCheck.StatusCode != http.StatusNotFound { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to check if file exists", + respCheck, + getErr, + ), nil, nil } + case dirContent != nil: + return utils.NewToolResultError(fmt.Sprintf( + "Path %s is a directory, not a file. This tool only works with files.", + path)), nil, nil + case existingFile != nil: + // File exists but no SHA was provided - reject to prevent blind overwrites + return utils.NewToolResultError(fmt.Sprintf( + "File already exists at %s. You must provide the current file's SHA when updating. "+ + "Use git rev-parse %s:%s to get the blob SHA, then retry with the sha parameter.", + path, branch, path)), nil, nil } - } - - if previousSHA != "" { - opts.SHA = github.Ptr(previousSHA) + // If file not found, no previous SHA needed (new file creation) } fileContent, resp, err := client.Repositories.CreateFile(ctx, owner, repo, path, opts) @@ -488,25 +565,9 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create/update file", resp, body), nil, nil } - r, err := json.Marshal(fileContent) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - // Warn if file was updated without SHA validation (blind update) - if sha == "" && previousSHA != "" { - return utils.NewToolResultText(fmt.Sprintf( - "Warning: File updated without SHA validation. Previous file SHA was %s. "+ - `Verify no unintended changes were overwritten: -1. Extract the SHA of the local version using git ls-tree HEAD %s. -2. Compare with the previous SHA above. -3. Revert changes if shas do not match. - -%s`, - previousSHA, path, string(r))), nil, nil - } + minimalResponse := convertToMinimalFileContentResponse(fileContent) - return utils.NewToolResultText(string(r)), nil, nil + return MarshalledTextResult(minimalResponse), nil, nil }, ) } @@ -539,7 +600,8 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool }, "private": { Type: "boolean", - Description: "Whether repo should be private", + Description: "Whether the repository should be private. Defaults to true (private) when omitted.", + Default: json.RawMessage("true"), }, "autoInit": { Type: "boolean", @@ -563,7 +625,7 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - private, err := OptionalParam[bool](args, "private") + private, err := OptionalBoolParamWithDefault(args, "private", true) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } @@ -617,6 +679,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( @@ -689,6 +765,15 @@ 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. + attachIFC := newRepoVisibilityIFCLabeler(ctx, deps, client, owner, repo, ifc.LabelGetFileContents) + 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 @@ -710,98 +795,99 @@ 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 { fileSHA = *fileContent.SHA - - rawClient, err := deps.GetRawClient(ctx) + fileSize := fileContent.GetSize() + // Build resource URI for the file using URI templates + pathParts := strings.Split(path, "/") + resourceURI, err := expandRepoResourceURI(owner, repo, sha, ref, pathParts) if err != nil { - return utils.NewToolResultError("failed to get GitHub raw content client"), nil, nil + return utils.NewToolResultError("failed to build resource URI"), nil, nil } - resp, err := rawClient.GetRawContent(ctx, owner, repo, path, rawOpts) - if err != nil { - return utils.NewToolResultError("failed to get raw repository content"), nil, nil + + // main branch ref passed in ref parameter but it doesn't exist - default branch was used + var successNote string + if fallbackUsed { + successNote = fmt.Sprintf(" Note: the provided ref '%s' does not exist, default branch '%s' was used instead.", originalRef, rawOpts.Ref) } - defer func() { - _ = resp.Body.Close() - }() - if resp.StatusCode == http.StatusOK { - // If the raw content is found, return it directly - body, err := io.ReadAll(resp.Body) - if err != nil { - return ghErrors.NewGitHubRawAPIErrorResponse(ctx, "failed to get raw repository content", resp, err), nil, nil - } - contentType := resp.Header.Get("Content-Type") - - var resourceURI string - switch { - case sha != "": - resourceURI, err = url.JoinPath("repo://", owner, repo, "sha", sha, "contents", path) - if err != nil { - return nil, nil, fmt.Errorf("failed to create resource URI: %w", err) - } - case ref != "": - resourceURI, err = url.JoinPath("repo://", owner, repo, ref, "contents", path) - if err != nil { - return nil, nil, fmt.Errorf("failed to create resource URI: %w", err) - } - default: - resourceURI, err = url.JoinPath("repo://", owner, repo, "contents", path) - if err != nil { - return nil, nil, fmt.Errorf("failed to create resource URI: %w", err) - } + // Empty files (0 bytes) have no content to decode; return + // them directly as empty text to avoid errors from + // GetContent when the API returns null content with a + // base64 encoding field, and to avoid DetectContentType + // misclassifying them as binary. + if fileSize == 0 { + result := &mcp.ResourceContents{ + URI: resourceURI, + Text: "", + MIMEType: "text/plain", } + return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil + } - // main branch ref passed in ref parameter but it doesn't exist - default branch was used - var successNote string - if fallbackUsed { - successNote = fmt.Sprintf(" Note: the provided ref '%s' does not exist, default branch '%s' was used instead.", originalRef, rawOpts.Ref) + // For files >= 1MB, return a ResourceLink instead of content + const maxContentSize = 1024 * 1024 // 1MB + if fileSize >= maxContentSize { + size := int64(fileSize) + resourceLink := &mcp.ResourceLink{ + URI: resourceURI, + Name: fileContent.GetName(), + Title: fmt.Sprintf("File: %s", path), + Size: &size, } + 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 + } - // Determine if content is text or binary - isTextContent := strings.HasPrefix(contentType, "text/") || - contentType == "application/json" || - contentType == "application/xml" || - strings.HasSuffix(contentType, "+json") || - strings.HasSuffix(contentType, "+xml") - - if isTextContent { - result := &mcp.ResourceContents{ - URI: resourceURI, - Text: string(body), - MIMEType: contentType, - } - // Include SHA in the result metadata - if fileSHA != "" { - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)", fileSHA)+successNote, result), nil, nil - } - return utils.NewToolResultResource("successfully downloaded text file"+successNote, result), nil, nil - } + // For files < 1MB, get content directly from Contents API + content, err := fileContent.GetContent() + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to decode file content: %s", err)), nil, nil + } + + // Detect content type from the actual content bytes, + // mirroring the original approach of using the Content-Type header + // from the raw API response. + contentBytes := []byte(content) + contentType := http.DetectContentType(contentBytes) + + // Determine if content is text or binary based on detected content type + isTextContent := strings.HasPrefix(contentType, "text/") || + contentType == "application/json" || + contentType == "application/xml" || + strings.HasSuffix(contentType, "+json") || + strings.HasSuffix(contentType, "+xml") + if isTextContent { result := &mcp.ResourceContents{ URI: resourceURI, - Blob: body, + Text: content, MIMEType: contentType, } - // Include SHA in the result metadata - if fileSHA != "" { - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)", fileSHA)+successNote, result), nil, nil - } - return utils.NewToolResultResource("successfully downloaded binary file"+successNote, result), nil, nil + return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil } - // Raw API call failed - return matchFiles(ctx, client, owner, repo, ref, path, rawOpts, resp.StatusCode) + // Binary content - encode as base64 blob + blobContent := base64.StdEncoding.EncodeToString(contentBytes) + result := &mcp.ResourceContents{ + URI: resourceURI, + Blob: []byte(blobContent), + MIMEType: contentType, + } + 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 @@ -1078,7 +1164,7 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { } // Create a response similar to what the DeleteFile API would return - response := map[string]interface{}{ + response := map[string]any{ "commit": newCommit, "content": nil, } @@ -1236,7 +1322,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", @@ -1278,7 +1365,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { } // Parse files parameter - this should be an array of objects with path and content - filesObj, ok := args["files"].([]interface{}) + filesObj, ok := args["files"].([]any) if !ok { return utils.NewToolResultError("files parameter must be an array of objects with path and content"), nil, nil } @@ -1360,7 +1447,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { var entries []*github.TreeEntry for _, file := range filesObj { - fileMap, ok := file.(map[string]interface{}) + fileMap, ok := file.(map[string]any) if !ok { return utils.NewToolResultError("each file must be an object with path and content"), nil, nil } @@ -1509,12 +1596,24 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list tags", resp, body), nil, nil } - r, err := json.Marshal(tags) + minimalTags := make([]MinimalTag, 0, len(tags)) + for _, tag := range tags { + if tag != nil { + minimalTags = append(minimalTags, convertToMinimalTag(tag)) + } + } + + r, err := json.Marshal(minimalTags) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Tags are structural repo metadata created by collaborators with + // push access, so integrity is trusted. Confidentiality follows + // repo visibility. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) + return result, nil, nil }, ) } @@ -1588,7 +1687,17 @@ 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) + } + result := utils.NewToolResultText(string(r)) + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) + return result, nil, nil + } + tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, @@ -1612,7 +1721,12 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // An annotated tag object is structural repo metadata created by a + // collaborator with push access. Confidentiality follows repo + // visibility. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) + return result, nil, nil }, ) } @@ -1682,12 +1796,36 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list releases", resp, body), nil, nil } - r, err := json.Marshal(releases) + minimalReleases := make([]MinimalRelease, 0, len(releases)) + for _, release := range releases { + if release != nil { + minimalReleases = append(minimalReleases, convertToMinimalRelease(release)) + } + } + + r, err := json.Marshal(minimalReleases) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Releases are published by collaborators with push access, so + // integrity is trusted. Confidentiality follows repo visibility, + // but draft releases are visible only to push-access users and are + // not world-readable even on a public repo, so the result is only + // public when no returned release is a draft. + hasDraft := false + for _, mr := range minimalReleases { + if mr.Draft { + hasDraft = true + break + } + } + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, + func(isPrivate bool) ifc.SecurityLabel { + return ifc.LabelRelease(isPrivate, hasDraft) + }) + return result, nil, nil }, ) } @@ -1753,7 +1891,16 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Releases are published by collaborators with push access, so + // integrity is trusted. The "latest release" endpoint never returns + // a draft, but the draft flag is honored defensively: a draft is + // not world-readable even on a public repo. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, + func(isPrivate bool) ifc.SecurityLabel { + return ifc.LabelRelease(isPrivate, release.GetDraft()) + }) + return result, nil, nil }, ) } @@ -1830,7 +1977,16 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Releases are published by collaborators with push access, so + // integrity is trusted. A release fetched by tag may be a draft, + // which is visible only to push-access users and not world-readable + // even on a public repo, so a draft forces private confidentiality. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, + func(isPrivate bool) ifc.SecurityLabel { + return ifc.LabelRelease(isPrivate, release.GetDraft()) + }) + return result, nil, nil }, ) } @@ -1962,7 +2118,19 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.Ser return nil, nil, fmt.Errorf("failed to marshal starred repositories: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // A starred-repository listing exposes repository data across many + // repos; reuse the multi-repo join shared with search_repositories + // (public-only results stay public-untrusted, mixed-visibility + // results become private-untrusted, all-private results become + // private-trusted). Visibility is read directly from the response, + // so no extra API call is needed. + visibilities := make([]bool, 0, len(minimalRepos)) + for _, mr := range minimalRepos { + visibilities = append(visibilities, mr.Private) + } + result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelSearchIssues) + return result, nil, nil }, ) } @@ -2097,3 +2265,536 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool }, ) } + +// maxBlameRanges caps the number of matching blame ranges considered for one response. +const maxBlameRanges = 1000 + +const blameCursorPrefix = "blame-range:" + +func encodeBlameCursor(offset int) string { + return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s%d", blameCursorPrefix, offset)) +} + +func decodeBlameCursor(cursor string) (int, error) { + if cursor == "" { + return 0, nil + } + + decoded, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + return 0, fmt.Errorf("after cursor is invalid") + } + + value := string(decoded) + if !strings.HasPrefix(value, blameCursorPrefix) { + return 0, fmt.Errorf("after cursor is invalid") + } + + offset, err := strconv.Atoi(strings.TrimPrefix(value, blameCursorPrefix)) + if err != nil || offset < 0 { + return 0, fmt.Errorf("after cursor is invalid") + } + + return offset, nil +} + +// BlameAuthor describes the author of a commit referenced by a BlameRange. +type BlameAuthor struct { + Name string `json:"name"` + Email string `json:"email"` + Login *string `json:"login,omitempty"` + URL *string `json:"url,omitempty"` +} + +// BlameCommit holds commit metadata shared by one or more blame ranges. +type BlameCommit struct { + SHA string `json:"sha"` + MessageHeadline string `json:"message_headline"` + CommittedDate string `json:"committed_date"` + Author BlameAuthor `json:"author"` +} + +// BlameRange is a contiguous run of lines attributed to a single commit. +// +// Age is the relative position of this range's commit among distinct commits +// touching the file (0 = newest), not an absolute time delta. See: +// https://docs.github.com/en/graphql/reference/objects#blamerange +type BlameRange struct { + StartingLine int `json:"starting_line"` + EndingLine int `json:"ending_line"` + Age int `json:"age"` + CommitSHA string `json:"commit_sha"` +} + +// BlameResult is the response payload returned by the get_file_blame tool. +// +// Commits is keyed by SHA. TotalRanges counts matching ranges before cursor +// pagination or truncation. Truncated reports whether maxBlameRanges was hit. +type BlameResult struct { + Repository string `json:"repository"` + Path string `json:"path"` + Ref string `json:"ref"` + Ranges []BlameRange `json:"ranges"` + Commits map[string]BlameCommit `json:"commits"` + PageInfo MinimalPageInfo `json:"pageInfo"` + TotalRanges int `json:"total_ranges"` + Truncated bool `json:"truncated,omitempty"` +} + +// blameCommitFragment is the GraphQL selection for a Commit's blame data. +type blameCommitFragment struct { + Blame struct { + Ranges []struct { + StartingLine githubv4.Int + EndingLine githubv4.Int + Age githubv4.Int + Commit struct { + OID githubv4.String + Message githubv4.String + CommittedDate githubv4.DateTime + Author struct { + Name githubv4.String + Email githubv4.String + User *struct { + Login githubv4.String + URL githubv4.String + } + } + } + } + } `graphql:"blame(path: $path)"` +} + +// validateBlamePath rejects empty, leading-slash, traversal-laden, or +// control-character paths before any network call is made. +func validateBlamePath(p string) error { + if strings.TrimSpace(p) == "" { + return fmt.Errorf("path must not be empty") + } + if strings.HasPrefix(p, "/") { + return fmt.Errorf("path must be relative to the repository root (no leading '/')") + } + if slices.Contains(strings.Split(p, "/"), "..") { + return fmt.Errorf("path must not contain '..' segments") + } + for _, r := range p { + if r < 0x20 || r == 0x7f { + return fmt.Errorf("path must not contain control characters") + } + } + return nil +} + +func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "get_file_blame", + Description: t("TOOL_GET_FILE_BLAME_DESCRIPTION", + "Get git blame information for a file, showing the commit that last modified each line. "+ + "Ranges share commit metadata via the top-level 'commits' map keyed by SHA. "+ + "Use 'start_line'/'end_line' to restrict the result to a window of the file, and "+ + "'perPage'/'after' to cursor-page through returned ranges. Matching ranges are capped at "+ + "1000; when the cap is hit 'truncated' is set to true and 'total_ranges' reports the pre-cap match count.", + ), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_FILE_BLAME_USER_TITLE", "Get file blame information"), + ReadOnlyHint: true, + }, + InputSchema: WithCursorPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "path": { + Type: "string", + Description: "Path to the file in the repository, relative to the repository root", + }, + "ref": { + Type: "string", + Description: "Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (HEAD).", + }, + "start_line": { + Type: "number", + Description: "Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window.", + Minimum: jsonschema.Ptr(1.0), + }, + "end_line": { + Type: "number", + Description: "Optional 1-based ending line of the window of interest. Must be >= start_line when both are provided.", + Minimum: jsonschema.Ptr(1.0), + }, + }, + Required: []string{"owner", "repo", "path"}, + }), + }, + []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 + } + path, err := RequiredParam[string](args, "path") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if err := validateBlamePath(path); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + ref, err := OptionalParam[string](args, "ref") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + _, hasStartLine := args["start_line"] + startLine, err := OptionalIntParam(args, "start_line") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if hasStartLine && startLine < 1 { + return utils.NewToolResultError("start_line must be omitted or >= 1"), nil, nil + } + _, hasEndLine := args["end_line"] + endLine, err := OptionalIntParam(args, "end_line") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if hasEndLine && endLine < 1 { + return utils.NewToolResultError("end_line must be omitted or >= 1"), nil, nil + } + if hasStartLine && hasEndLine && endLine < startLine { + return utils.NewToolResultError("end_line must be >= start_line when both are provided"), nil, nil + } + if _, hasPage := args["page"]; hasPage { + 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 + } + pagination, err := OptionalCursorPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if _, hasPerPage := args["perPage"]; hasPerPage { + perPage, err := OptionalIntParam(args, "perPage") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if perPage < 1 || perPage > 100 { + return utils.NewToolResultError("perPage must be between 1 and 100 when provided"), nil, nil + } + pagination.PerPage = perPage + } + afterOffset, err := decodeBlameCursor(pagination.After) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub GraphQL client: %w", err) + } + + // Default to HEAD and fetch defaultBranchRef.name in the same query + // so the response can echo a readable ref. + refExpression := ref + if refExpression == "" { + refExpression = "HEAD" + } + + var blameQuery struct { + Repository struct { + DefaultBranchRef struct { + Name githubv4.String + } + Object struct { + Typename githubv4.String `graphql:"__typename"` + Commit blameCommitFragment `graphql:"... on Commit"` + // Annotated tag targets are followed one level. Tag-of-tag + // chains are not followed and will return an error. + Tag struct { + Target struct { + Typename githubv4.String `graphql:"__typename"` + Commit blameCommitFragment `graphql:"... on Commit"` + } + } `graphql:"... on Tag"` + } `graphql:"object(expression: $ref)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "ref": githubv4.String(refExpression), + "path": githubv4.String(path), + } + + if err := client.Query(ctx, &blameQuery, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + fmt.Sprintf("failed to get blame for file: %s", path), + err, + ), nil, nil + } + + // GitHub's Commit.blame field accepts only path, and Blame.ranges is + // not a connection, so cursor pagination is applied locally below. + // The ref must resolve to a commit, either directly or via an annotated tag. + objectTypename := string(blameQuery.Repository.Object.Typename) + if objectTypename == "" { + return utils.NewToolResultError( + fmt.Sprintf("ref %q was not found in %s/%s", refExpression, owner, repo), + ), nil, nil + } + blameCommit := &blameQuery.Repository.Object.Commit + if objectTypename == "Tag" { + targetTypename := string(blameQuery.Repository.Object.Tag.Target.Typename) + if targetTypename != "Commit" { + if targetTypename == "" { + targetTypename = "unknown" + } + return utils.NewToolResultError( + fmt.Sprintf("ref %q resolved to a tag in %s/%s, but the tag target did not resolve to a commit (resolved to %s)", + refExpression, owner, repo, targetTypename), + ), nil, nil + } + blameCommit = &blameQuery.Repository.Object.Tag.Target.Commit + } else if objectTypename != "Commit" { + return utils.NewToolResultError( + fmt.Sprintf("ref %q did not resolve to a commit in %s/%s (resolved to %s)", + refExpression, owner, repo, objectTypename), + ), nil, nil + } + + // Echo the caller's ref, otherwise prefer the default branch name. + responseRef := ref + if responseRef == "" { + if name := string(blameQuery.Repository.DefaultBranchRef.Name); name != "" { + responseRef = name + } else { + responseRef = refExpression + } + } + + rawRanges := blameCommit.Blame.Ranges + pageRanges := make([]BlameRange, 0, pagination.PerPage) + commits := make(map[string]BlameCommit) + totalRanges := 0 + truncated := false + + for _, r := range rawRanges { + start := int(r.StartingLine) + end := int(r.EndingLine) + if startLine > 0 && end < startLine { + continue + } + if endLine > 0 && start > endLine { + continue + } + if startLine > 0 && start < startLine { + start = startLine + } + if endLine > 0 && end > endLine { + end = endLine + } + + matchIndex := totalRanges + totalRanges++ + if matchIndex >= maxBlameRanges { + truncated = true + continue + } + if matchIndex < afterOffset || len(pageRanges) >= pagination.PerPage { + continue + } + + blameRange := BlameRange{ + StartingLine: start, + EndingLine: end, + Age: int(r.Age), + CommitSHA: string(r.Commit.OID), + } + pageRanges = append(pageRanges, blameRange) + + sha := string(r.Commit.OID) + if _, seen := commits[sha]; seen { + continue + } + headline := string(r.Commit.Message) + if idx := strings.IndexByte(headline, '\n'); idx >= 0 { + headline = headline[:idx] + } + headline = strings.TrimRight(headline, " \t\r") + bc := BlameCommit{ + SHA: sha, + MessageHeadline: headline, + CommittedDate: r.Commit.CommittedDate.Format("2006-01-02T15:04:05Z"), + Author: BlameAuthor{ + Name: string(r.Commit.Author.Name), + Email: string(r.Commit.Author.Email), + }, + } + if r.Commit.Author.User != nil { + login := string(r.Commit.Author.User.Login) + url := string(r.Commit.Author.User.URL) + bc.Author.Login = &login + bc.Author.URL = &url + } + commits[sha] = bc + } + + cappedRanges := min(totalRanges, maxBlameRanges) + consumedRanges := min(afterOffset+len(pageRanges), cappedRanges) + pageInfo := MinimalPageInfo{ + HasNextPage: consumedRanges < cappedRanges, + HasPreviousPage: afterOffset > 0, + } + if len(pageRanges) > 0 { + pageInfo.StartCursor = encodeBlameCursor(afterOffset) + pageInfo.EndCursor = encodeBlameCursor(consumedRanges) + } + + result := BlameResult{ + Repository: fmt.Sprintf("%s/%s", owner, repo), + Path: path, + Ref: responseRef, + Ranges: pageRanges, + Commits: commits, + PageInfo: pageInfo, + TotalRanges: totalRanges, + Truncated: truncated, + } + if result.Ranges == nil { + result.Ranges = []BlameRange{} + } + + payload, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(payload)), nil, nil + }, + ) + st.FeatureFlagEnable = FeatureFlagFileBlame + return st +} + +// 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, + } + + callResult := MarshalledTextResult(response) + // The collaborator roster is GitHub-maintained membership data + // (trusted, not attacker-authored). Listing collaborators requires + // push access, so the roster is never world-readable — not even on + // a public repo — hence always private confidentiality. + callResult = attachStaticIFCLabel(ctx, deps, callResult, ifc.LabelCollaboratorRoster()) + return callResult, nil, nil + }, + ) +} diff --git a/pkg/github/repositories_helper.go b/pkg/github/repositories_helper.go index de5065d480..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/v79/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 d91af8851b..e5531cc55b 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -10,13 +10,15 @@ import ( "testing" "time" + "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -64,9 +66,9 @@ func Test_GetFileContents(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool - expectedResult interface{} + expectedResult any expectedErrMsg string expectStatus int expectedMsg string // optional: expected message text to verify in result @@ -78,21 +80,22 @@ func Test_GetFileContents(t *testing.T) { GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) + // Base64 encode the content as GitHub API does + 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"), + 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) }, - GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "README.md", @@ -102,31 +105,33 @@ func Test_GetFileContents(t *testing.T) { expectedResult: mcp.ResourceContents{ URI: "repo://owner/repo/refs/heads/main/contents/README.md", Text: "# Test Repository\n\nThis is a test repository.", - MIMEType: "text/markdown", + MIMEType: "text/plain; charset=utf-8", }, }, { - name: "successful file blob content fetch", + name: "successful binary file content fetch (PNG)", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) + // PNG magic bytes followed by some data + pngContent := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01") + encodedContent := base64.StdEncoding.EncodeToString(pngContent) fileContent := &github.RepositoryContent{ - Name: github.Ptr("test.png"), - Path: github.Ptr("test.png"), - SHA: github.Ptr("def456"), - Type: github.Ptr("file"), + Name: github.Ptr("test.png"), + Path: github.Ptr("test.png"), + SHA: github.Ptr("def456"), + Type: github.Ptr("file"), + Content: github.Ptr(encodedContent), + Size: github.Ptr(len(pngContent)), + Encoding: github.Ptr("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) }, - GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "image/png") - _, _ = w.Write(mockRawContent) - }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "test.png", @@ -135,32 +140,34 @@ func Test_GetFileContents(t *testing.T) { expectError: false, expectedResult: mcp.ResourceContents{ URI: "repo://owner/repo/refs/heads/main/contents/test.png", - Blob: mockRawContent, + Blob: []byte(base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"))), MIMEType: "image/png", }, }, { - name: "successful PDF file content fetch", + name: "successful binary file content fetch (PDF)", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) + // PDF magic bytes + pdfContent := []byte("%PDF-1.4 fake pdf content") + encodedContent := base64.StdEncoding.EncodeToString(pdfContent) fileContent := &github.RepositoryContent{ - Name: github.Ptr("document.pdf"), - Path: github.Ptr("document.pdf"), - SHA: github.Ptr("pdf123"), - Type: github.Ptr("file"), + Name: github.Ptr("document.pdf"), + Path: github.Ptr("document.pdf"), + SHA: github.Ptr("pdf123"), + Type: github.Ptr("file"), + Content: github.Ptr(encodedContent), + Size: github.Ptr(len(pdfContent)), + Encoding: github.Ptr("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) }, - GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/pdf") - _, _ = w.Write(mockRawContent) - }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "document.pdf", @@ -169,7 +176,7 @@ func Test_GetFileContents(t *testing.T) { expectError: false, expectedResult: mcp.ResourceContents{ URI: "repo://owner/repo/refs/heads/main/contents/document.pdf", - Blob: mockRawContent, + Blob: []byte(base64.StdEncoding.EncodeToString([]byte("%PDF-1.4 fake pdf content"))), MIMEType: "application/pdf", }, }, @@ -185,7 +192,7 @@ func Test_GetFileContents(t *testing.T) { mockResponse(t, http.StatusNotFound, nil), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "src/", @@ -200,21 +207,22 @@ func Test_GetFileContents(t *testing.T) { GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) + // Base64 encode the content as GitHub API does + 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"), + 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) }, - GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "/README.md", @@ -224,7 +232,7 @@ func Test_GetFileContents(t *testing.T) { expectedResult: mcp.ResourceContents{ URI: "repo://owner/repo/refs/heads/main/contents/README.md", Text: "# Test Repository\n\nThis is a test repository.", - MIMEType: "text/markdown", + MIMEType: "text/plain; charset=utf-8", }, }, { @@ -239,7 +247,7 @@ func Test_GetFileContents(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) case strings.Contains(path, "heads/develop"): w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456abc123def456abc123def456abc1", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456abc123def456abc123def456abc1"}}`)) default: w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -253,7 +261,7 @@ func Test_GetFileContents(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) case strings.Contains(path, "heads/develop"): w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456abc123def456abc123def456abc1", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456abc123def456abc123def456abc1"}}`)) default: w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -267,7 +275,7 @@ func Test_GetFileContents(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) case strings.Contains(path, "heads/develop"): w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456abc123def456abc123def456abc1", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456abc123def456abc123def456abc1"}}`)) default: w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -279,33 +287,26 @@ func Test_GetFileContents(t *testing.T) { }, "GET /repos/owner/repo/git/ref/heads/develop": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456abc123def456abc123def456abc1", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456abc123def456abc123def456abc1"}}`)) }, GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) + // Base64 encode the content as GitHub API does + 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"), + 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) }, - "GET /owner/repo/refs/heads/develop/README.md": func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }, - "GET /owner/repo/refs%2Fheads%2Fdevelop/README.md": func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }, - "GET /owner/repo/abc123def456/README.md": func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "README.md", @@ -313,12 +314,79 @@ func Test_GetFileContents(t *testing.T) { }, expectError: false, expectedResult: mcp.ResourceContents{ - URI: "repo://owner/repo/abc123def456/contents/README.md", + URI: "repo://owner/repo/sha/abc123def456abc123def456abc123def456abc1/contents/README.md", Text: "# Test Repository\n\nThis is a test repository.", - MIMEType: "text/markdown", + MIMEType: "text/plain; charset=utf-8", }, expectedMsg: " Note: the provided ref 'main' does not exist, default branch 'refs/heads/develop' was used instead.", }, + { + name: "large file returns ResourceLink", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + // File larger than 1MB - Contents API returns metadata but no content + fileContent := &github.RepositoryContent{ + Name: github.Ptr("large-file.bin"), + Path: github.Ptr("large-file.bin"), + SHA: github.Ptr("largesha123"), + Type: github.Ptr("file"), + Size: github.Ptr(2 * 1024 * 1024), // 2MB + DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/large-file.bin"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "large-file.bin", + "ref": "refs/heads/main", + }, + expectError: false, + expectedResult: &mcp.ResourceLink{ + URI: "repo://owner/repo/refs/heads/main/contents/large-file.bin", + Name: "large-file.bin", + Title: "File: large-file.bin", + }, + }, + { + name: "successful empty file content fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr(".gitkeep"), + Path: github.Ptr(".gitkeep"), + SHA: github.Ptr("empty123"), + Type: github.Ptr("file"), + Content: nil, + Size: github.Ptr(0), + Encoding: github.Ptr("base64"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": ".gitkeep", + "ref": "refs/heads/main", + }, + expectError: false, + expectedResult: mcp.ResourceContents{ + URI: "repo://owner/repo/refs/heads/main/contents/.gitkeep", + Text: "", + MIMEType: "text/plain", + }, + expectedMsg: "successfully downloaded empty file", + }, { name: "content fetch fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -332,7 +400,7 @@ func Test_GetFileContents(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "nonexistent.md", @@ -346,8 +414,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, @@ -395,6 +464,14 @@ func Test_GetFileContents(t *testing.T) { assert.Equal(t, *expected[i].Path, *content.Path) assert.Equal(t, *expected[i].Type, *content.Type) } + case *mcp.ResourceLink: + // Large file returns a ResourceLink + require.Len(t, result.Content, 2) + resourceLink, ok := result.Content[1].(*mcp.ResourceLink) + require.True(t, ok, "expected Content[1] to be ResourceLink") + assert.Equal(t, expected.URI, resourceLink.URI) + assert.Equal(t, expected.Name, resourceLink.Name) + assert.Equal(t, expected.Title, resourceLink.Title) case mcp.TextContent: textContent := getErrorResult(t, result) require.Equal(t, textContent, expected) @@ -403,6 +480,269 @@ 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") + } + }) +} + +// Test_GetCommit_IFC_FeatureFlag verifies that the IFC security label is only +// attached to get_commit results when the ifc_labels feature flag is enabled, +// and that the label content matches the commit-contents rule (untrusted on +// public repos, trusted on private). It also confirms the label is omitted +// when the repository visibility lookup fails, so the result is never +// misclassified. get_commit is representative of every tool wired through the +// shared attachRepoVisibilityIFCLabel helper. +func Test_GetCommit_IFC_FeatureFlag(t *testing.T) { + t.Parallel() + + serverTool := GetCommit(translations.NullTranslationHelper) + + mockCommit := &github.RepositoryCommit{ + SHA: github.Ptr("abc123def456"), + Commit: &github.Commit{Message: github.Ptr("First commit")}, + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + } + + makeMockClient := func(isPrivate bool) *http.Client { + return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{ + "name": "repo", + "private": isPrivate, + }), + }) + } + + reqParams := map[string]any{ + "owner": "owner", + "repo": "repo", + "sha": "abc123def456", + } + + t.Run("feature flag 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 IFC labels are disabled") + }) + + t.Run("feature flag 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("feature flag 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("feature flag enabled skips ifc label when visibility lookup fails", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit), + GetReposByOwnerByRepo: mockResponse(t, http.StatusInternalServerError, "boom"), + }) + 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) @@ -436,7 +776,7 @@ func Test_ForkRepository(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedRepo *github.Repository expectedErrMsg string @@ -446,7 +786,7 @@ func Test_ForkRepository(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposForksByOwnerByRepo: mockResponse(t, http.StatusAccepted, mockForkedRepo), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -461,7 +801,7 @@ func Test_ForkRepository(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -473,7 +813,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, } @@ -546,7 +886,7 @@ func Test_CreateBranch(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedRef *github.Reference expectedErrMsg string @@ -558,7 +898,7 @@ func Test_CreateBranch(t *testing.T) { "GET /repos/owner/repo/git/ref/heads/main": mockResponse(t, http.StatusOK, mockSourceRef), PostReposGitRefsByOwnerByRepo: mockResponse(t, http.StatusCreated, mockCreatedRef), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "branch": "new-feature", @@ -573,14 +913,14 @@ func Test_CreateBranch(t *testing.T) { GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, mockRepo), GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockSourceRef), "GET /repos/owner/repo/git/ref/heads/main": mockResponse(t, http.StatusOK, mockSourceRef), - PostReposGitRefsByOwnerByRepo: expectRequestBody(t, map[string]interface{}{ + PostReposGitRefsByOwnerByRepo: expectRequestBody(t, map[string]any{ "ref": "refs/heads/new-feature", "sha": "abc123def456", }).andThen( mockResponse(t, http.StatusCreated, mockCreatedRef), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "branch": "new-feature", @@ -596,7 +936,7 @@ func Test_CreateBranch(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Repository not found"}`)) }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "nonexistent-repo", "branch": "new-feature", @@ -612,7 +952,7 @@ func Test_CreateBranch(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "branch": "new-feature", @@ -631,7 +971,7 @@ func Test_CreateBranch(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Reference already exists"}`)) }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "branch": "existing-branch", @@ -645,7 +985,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, } @@ -732,7 +1072,7 @@ func Test_GetCommit(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedCommit *github.RepositoryCommit expectedErrMsg string @@ -742,7 +1082,7 @@ func Test_GetCommit(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "sha": "abc123def456", @@ -758,7 +1098,7 @@ func Test_GetCommit(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "sha": "nonexistent-sha", @@ -771,7 +1111,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, } @@ -811,6 +1151,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) @@ -826,6 +1280,9 @@ func Test_ListCommits(t *testing.T) { assert.Contains(t, schema.Properties, "repo") assert.Contains(t, schema.Properties, "sha") assert.Contains(t, schema.Properties, "author") + assert.Contains(t, schema.Properties, "path") + assert.Contains(t, schema.Properties, "since") + assert.Contains(t, schema.Properties, "until") assert.Contains(t, schema.Properties, "page") assert.Contains(t, schema.Properties, "perPage") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) @@ -908,7 +1365,7 @@ func Test_ListCommits(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedCommits []*github.RepositoryCommit expectedErrMsg string @@ -918,7 +1375,7 @@ func Test_ListCommits(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposCommitsByOwnerByRepo: mockResponse(t, http.StatusOK, mockCommits), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -937,7 +1394,7 @@ func Test_ListCommits(t *testing.T) { mockResponse(t, http.StatusOK, mockCommits), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "sha": "main", @@ -946,6 +1403,80 @@ func Test_ListCommits(t *testing.T) { expectError: false, expectedCommits: mockCommits, }, + { + name: "successful commits fetch with path filter", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "path": "src/main.go", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "src/main.go", + }, + expectError: false, + expectedCommits: mockCommits, + }, + { + name: "successful commits fetch with since and until", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "since": "2023-01-01T00:00:00Z", + "until": "2023-12-31T23:59:59Z", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "since": "2023-01-01T00:00:00Z", + "until": "2023-12-31T23:59:59Z", + }, + expectError: false, + expectedCommits: mockCommits, + }, + { + name: "successful commits fetch with path, since, and author", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "path": "projects/plugins/boost", + "since": "2023-06-15T00:00:00Z", + "author": "username", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "projects/plugins/boost", + "since": "2023-06-15T00:00:00Z", + "author": "username", + }, + expectError: false, + expectedCommits: mockCommits, + }, + { + name: "invalid since timestamp returns error", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "since": "not-a-date", + }, + expectError: true, + expectedErrMsg: "invalid since timestamp", + }, { name: "successful commits fetch with pagination", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -956,7 +1487,7 @@ func Test_ListCommits(t *testing.T) { mockResponse(t, http.StatusOK, mockCommits), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "page": float64(2), @@ -973,7 +1504,7 @@ func Test_ListCommits(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "nonexistent-repo", }, @@ -985,7 +1516,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, } @@ -1080,7 +1611,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedContent *github.RepositoryContentResponse expectedErrMsg string @@ -1088,14 +1619,14 @@ func Test_CreateOrUpdateFile(t *testing.T) { { name: "successful file creation", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ "message": "Add example file", "content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content "branch": "main", }).andThen( mockResponse(t, http.StatusOK, mockFileResponse), ), - "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]any{ "message": "Add example file", "content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content "branch": "main", @@ -1103,7 +1634,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { mockResponse(t, http.StatusOK, mockFileResponse), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "docs/example.md", @@ -1117,7 +1648,15 @@ func Test_CreateOrUpdateFile(t *testing.T) { { name: "successful file update with SHA", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("file"), + }), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("file"), + }), + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ "message": "Update example file", "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content "branch": "main", @@ -1125,7 +1664,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { }).andThen( mockResponse(t, http.StatusOK, mockFileResponse), ), - "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]any{ "message": "Update example file", "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content "branch": "main", @@ -1134,7 +1673,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { mockResponse(t, http.StatusOK, mockFileResponse), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "docs/example.md", @@ -1158,7 +1697,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) }, }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "docs/example.md", @@ -1170,27 +1709,17 @@ func Test_CreateOrUpdateFile(t *testing.T) { expectedErrMsg: "failed to create/update file", }, { - name: "sha validation - current sha matches (304 Not Modified)", + name: "sha validation - current sha matches", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, req *http.Request) { - ifNoneMatch := req.Header.Get("If-None-Match") - if ifNoneMatch == `"abc123def456"` { - w.WriteHeader(http.StatusNotModified) - } else { - w.WriteHeader(http.StatusOK) - w.Header().Set("ETag", `"abc123def456"`) - } - }, - "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, req *http.Request) { - ifNoneMatch := req.Header.Get("If-None-Match") - if ifNoneMatch == `"abc123def456"` { - w.WriteHeader(http.StatusNotModified) - } else { - w.WriteHeader(http.StatusOK) - w.Header().Set("ETag", `"abc123def456"`) - } - }, - PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("file"), + }), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("file"), + }), + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ "message": "Update example file", "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", "branch": "main", @@ -1198,7 +1727,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { }).andThen( mockResponse(t, http.StatusOK, mockFileResponse), ), - "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]any{ "message": "Update example file", "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", "branch": "main", @@ -1207,7 +1736,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { mockResponse(t, http.StatusOK, mockFileResponse), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "docs/example.md", @@ -1220,18 +1749,18 @@ func Test_CreateOrUpdateFile(t *testing.T) { expectedContent: mockFileResponse, }, { - name: "sha validation - stale sha detected (200 OK with different ETag)", + name: "sha validation - stale sha detected", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("ETag", `"newsha999888"`) - w.WriteHeader(http.StatusOK) - }, - "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("ETag", `"newsha999888"`) - w.WriteHeader(http.StatusOK) - }, + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("newsha999888"), + Type: github.Ptr("file"), + }), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("newsha999888"), + Type: github.Ptr("file"), + }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "docs/example.md", @@ -1246,10 +1775,13 @@ func Test_CreateOrUpdateFile(t *testing.T) { { name: "sha validation - file doesn't exist (404), proceed with create", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + "GET /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + "GET /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) }, - PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ "message": "Create new file", "content": "IyBOZXcgRmlsZQoKVGhpcyBpcyBhIG5ldyBmaWxlLg==", "branch": "main", @@ -1257,10 +1789,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { }).andThen( mockResponse(t, http.StatusCreated, mockFileResponse), ), - "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - }, - "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]any{ "message": "Create new file", "content": "IyBOZXcgRmlsZQoKVGhpcyBpcyBhIG5ldyBmaWxlLg==", "branch": "main", @@ -1269,7 +1798,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { mockResponse(t, http.StatusCreated, mockFileResponse), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "docs/example.md", @@ -1282,34 +1811,18 @@ func Test_CreateOrUpdateFile(t *testing.T) { expectedContent: mockFileResponse, }, { - name: "no sha provided - file exists, returns warning", + name: "no sha provided - file exists, rejects update", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("ETag", `"existing123"`) - w.WriteHeader(http.StatusOK) - }, - PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ - "message": "Update without SHA", - "content": "IyBVcGRhdGVkCgpVcGRhdGVkIHdpdGhvdXQgU0hBLg==", - "branch": "main", - "sha": "existing123", // SHA is automatically added from ETag - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), - "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("ETag", `"existing123"`) - w.WriteHeader(http.StatusOK) - }, - "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ - "message": "Update without SHA", - "content": "IyBVcGRhdGVkCgpVcGRhdGVkIHdpdGhvdXQgU0hBLg==", - "branch": "main", - "sha": "existing123", // SHA is automatically added from ETag - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("existing123"), + Type: github.Ptr("file"), + }), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("existing123"), + Type: github.Ptr("file"), + }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "docs/example.md", @@ -1317,26 +1830,26 @@ func Test_CreateOrUpdateFile(t *testing.T) { "message": "Update without SHA", "branch": "main", }, - expectError: false, - expectedErrMsg: "Warning: File updated without SHA validation. Previous file SHA was existing123", + expectError: true, + expectedErrMsg: "File already exists at docs/example.md", }, { name: "no sha provided - file doesn't exist, no warning", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + "GET /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + "GET /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) }, - PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ "message": "Create new file", "content": "IyBOZXcgRmlsZQoKQ3JlYXRlZCB3aXRob3V0IFNIQQ==", "branch": "main", }).andThen( mockResponse(t, http.StatusCreated, mockFileResponse), ), - "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - }, - "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]any{ "message": "Create new file", "content": "IyBOZXcgRmlsZQoKQ3JlYXRlZCB3aXRob3V0IFNIQQ==", "branch": "main", @@ -1344,7 +1857,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { mockResponse(t, http.StatusCreated, mockFileResponse), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "docs/example.md", @@ -1360,7 +1873,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, } @@ -1394,18 +1907,27 @@ func Test_CreateOrUpdateFile(t *testing.T) { } // Unmarshal and verify the result - var returnedContent github.RepositoryContentResponse + var returnedContent MinimalFileContentResponse err = json.Unmarshal([]byte(textContent.Text), &returnedContent) require.NoError(t, err) // Verify content - assert.Equal(t, *tc.expectedContent.Content.Name, *returnedContent.Content.Name) - assert.Equal(t, *tc.expectedContent.Content.Path, *returnedContent.Content.Path) - assert.Equal(t, *tc.expectedContent.Content.SHA, *returnedContent.Content.SHA) + assert.Equal(t, tc.expectedContent.Content.GetName(), returnedContent.Content.Name) + assert.Equal(t, tc.expectedContent.Content.GetPath(), returnedContent.Content.Path) + assert.Equal(t, tc.expectedContent.Content.GetSHA(), returnedContent.Content.SHA) + assert.Equal(t, tc.expectedContent.Content.GetSize(), returnedContent.Content.Size) + assert.Equal(t, tc.expectedContent.Content.GetHTMLURL(), returnedContent.Content.HTMLURL) // Verify commit - assert.Equal(t, *tc.expectedContent.Commit.SHA, *returnedContent.Commit.SHA) - assert.Equal(t, *tc.expectedContent.Commit.Message, *returnedContent.Commit.Message) + assert.Equal(t, tc.expectedContent.Commit.GetSHA(), returnedContent.Commit.SHA) + assert.Equal(t, tc.expectedContent.Commit.GetMessage(), returnedContent.Commit.Message) + assert.Equal(t, tc.expectedContent.Commit.GetHTMLURL(), returnedContent.Commit.HTMLURL) + + // Verify commit author + require.NotNil(t, returnedContent.Commit.Author) + assert.Equal(t, tc.expectedContent.Commit.Author.GetName(), returnedContent.Commit.Author.Name) + assert.Equal(t, tc.expectedContent.Commit.Author.GetEmail(), returnedContent.Commit.Author.Email) + assert.NotEmpty(t, returnedContent.Commit.Author.Date) }) } } @@ -1443,7 +1965,7 @@ func Test_CreateRepository(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedRepo *github.Repository expectedErrMsg string @@ -1453,7 +1975,7 @@ func Test_CreateRepository(t *testing.T) { mockedClient: NewMockedHTTPClient( WithRequestMatchHandler( EndpointPattern("POST /user/repos"), - expectRequestBody(t, map[string]interface{}{ + expectRequestBody(t, map[string]any{ "name": "test-repo", "description": "Test repository", "private": true, @@ -1463,7 +1985,7 @@ func Test_CreateRepository(t *testing.T) { ), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "name": "test-repo", "description": "Test repository", "private": true, @@ -1477,7 +1999,7 @@ func Test_CreateRepository(t *testing.T) { mockedClient: NewMockedHTTPClient( WithRequestMatchHandler( EndpointPattern("POST /orgs/testorg/repos"), - expectRequestBody(t, map[string]interface{}{ + expectRequestBody(t, map[string]any{ "name": "test-repo", "description": "Test repository", "private": false, @@ -1487,7 +2009,7 @@ func Test_CreateRepository(t *testing.T) { ), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "name": "test-repo", "description": "Test repository", "organization": "testorg", @@ -1498,26 +2020,48 @@ func Test_CreateRepository(t *testing.T) { expectedRepo: mockRepo, }, { - name: "successful repository creation with minimal parameters", + name: "successful repository creation with minimal parameters defaults to private", mockedClient: NewMockedHTTPClient( WithRequestMatchHandler( EndpointPattern("POST /user/repos"), - expectRequestBody(t, map[string]interface{}{ + expectRequestBody(t, map[string]any{ "name": "test-repo", "auto_init": false, "description": "", - "private": false, + "private": true, }).andThen( mockResponse(t, http.StatusCreated, mockRepo), ), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "name": "test-repo", }, expectError: false, expectedRepo: mockRepo, }, + { + name: "successful public repository creation when private is explicitly false", + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + EndpointPattern("POST /user/repos"), + expectRequestBody(t, map[string]any{ + "name": "test-repo", + "auto_init": false, + "description": "", + "private": false, + }).andThen( + mockResponse(t, http.StatusCreated, mockRepo), + ), + ), + ), + requestArgs: map[string]any{ + "name": "test-repo", + "private": false, + }, + expectError: false, + expectedRepo: mockRepo, + }, { name: "repository creation fails", mockedClient: NewMockedHTTPClient( @@ -1529,7 +2073,7 @@ func Test_CreateRepository(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "name": "invalid-repo", }, expectError: true, @@ -1540,7 +2084,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, } @@ -1634,7 +2178,7 @@ func Test_PushFiles(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedRef *github.Reference expectedErrMsg string @@ -1655,16 +2199,16 @@ func Test_PushFiles(t *testing.T) { // Create tree WithRequestMatchHandler( PostReposGitTreesByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ + expectRequestBody(t, map[string]any{ "base_tree": "def456", - "tree": []interface{}{ - map[string]interface{}{ + "tree": []any{ + map[string]any{ "path": "README.md", "mode": "100644", "type": "blob", "content": "# Updated README\n\nThis is an updated README file.", }, - map[string]interface{}{ + map[string]any{ "path": "docs/example.md", "mode": "100644", "type": "blob", @@ -1678,10 +2222,10 @@ func Test_PushFiles(t *testing.T) { // Create commit WithRequestMatchHandler( PostReposGitCommitsByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ + expectRequestBody(t, map[string]any{ "message": "Update multiple files", "tree": "ghi789", - "parents": []interface{}{"abc123"}, + "parents": []any{"abc123"}, }).andThen( mockResponse(t, http.StatusCreated, mockNewCommit), ), @@ -1689,7 +2233,7 @@ func Test_PushFiles(t *testing.T) { // Update reference WithRequestMatchHandler( PatchReposGitRefsByOwnerByRepoByRef, - expectRequestBody(t, map[string]interface{}{ + expectRequestBody(t, map[string]any{ "sha": "jkl012", "force": false, }).andThen( @@ -1697,16 +2241,16 @@ func Test_PushFiles(t *testing.T) { ), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "branch": "main", - "files": []interface{}{ - map[string]interface{}{ + "files": []any{ + map[string]any{ "path": "README.md", "content": "# Updated README\n\nThis is an updated README file.", }, - map[string]interface{}{ + map[string]any{ "path": "docs/example.md", "content": "# Example\n\nThis is an example file.", }, @@ -1721,7 +2265,7 @@ func Test_PushFiles(t *testing.T) { mockedClient: NewMockedHTTPClient( // No requests expected ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "branch": "main", @@ -1745,12 +2289,12 @@ func Test_PushFiles(t *testing.T) { mockCommit, ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "branch": "main", - "files": []interface{}{ - map[string]interface{}{ + "files": []any{ + map[string]any{ "content": "# Missing path", }, }, @@ -1773,12 +2317,12 @@ func Test_PushFiles(t *testing.T) { mockCommit, ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "branch": "main", - "files": []interface{}{ - map[string]interface{}{ + "files": []any{ + map[string]any{ "path": "README.md", // Missing content }, @@ -1801,12 +2345,12 @@ func Test_PushFiles(t *testing.T) { mockResponse(t, http.StatusNotFound, nil), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "branch": "non-existent-branch", - "files": []interface{}{ - map[string]interface{}{ + "files": []any{ + map[string]any{ "path": "README.md", "content": "# README", }, @@ -1830,12 +2374,12 @@ func Test_PushFiles(t *testing.T) { mockResponse(t, http.StatusNotFound, nil), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "branch": "main", - "files": []interface{}{ - map[string]interface{}{ + "files": []any{ + map[string]any{ "path": "README.md", "content": "# README", }, @@ -1864,12 +2408,12 @@ func Test_PushFiles(t *testing.T) { mockResponse(t, http.StatusInternalServerError, nil), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "branch": "main", - "files": []interface{}{ - map[string]interface{}{ + "files": []any{ + map[string]any{ "path": "README.md", "content": "# README", }, @@ -1893,7 +2437,7 @@ func Test_PushFiles(t *testing.T) { if callCount == 1 { // First call: empty repo w.WriteHeader(http.StatusConflict) - response := map[string]interface{}{ + response := map[string]any{ "message": "Git Repository is empty.", } _ = json.NewEncoder(w).Encode(response) @@ -1916,7 +2460,7 @@ func Test_PushFiles(t *testing.T) { WithRequestMatchHandler( PutReposContentsByOwnerByRepoByPath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var body map[string]interface{} + var body map[string]any err := json.NewDecoder(r.Body).Decode(&body) require.NoError(t, err) require.Equal(t, "Initial commit", body["message"]) @@ -1950,12 +2494,12 @@ func Test_PushFiles(t *testing.T) { mockUpdatedRef, ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "branch": "main", - "files": []interface{}{ - map[string]interface{}{ + "files": []any{ + map[string]any{ "path": "README.md", "content": "# Initial README\n\nFirst commit to empty repository.", }, @@ -1979,7 +2523,7 @@ func Test_PushFiles(t *testing.T) { // First call: returns 409 Conflict for empty repo w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusConflict) - response := map[string]interface{}{ + response := map[string]any{ "message": "Git Repository is empty.", } _ = json.NewEncoder(w).Encode(response) @@ -2006,7 +2550,7 @@ func Test_PushFiles(t *testing.T) { WithRequestMatchHandler( PutReposContentsByOwnerByRepoByPath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var body map[string]interface{} + var body map[string]any err := json.NewDecoder(r.Body).Decode(&body) require.NoError(t, err) require.Equal(t, "Initial commit", body["message"]) @@ -2048,22 +2592,22 @@ func Test_PushFiles(t *testing.T) { // Create tree with all user files WithRequestMatchHandler( PostReposGitTreesByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ + expectRequestBody(t, map[string]any{ "base_tree": "tree456", - "tree": []interface{}{ - map[string]interface{}{ + "tree": []any{ + map[string]any{ "path": "README.md", "mode": "100644", "type": "blob", "content": "# Project\n\nProject README", }, - map[string]interface{}{ + map[string]any{ "path": ".gitignore", "mode": "100644", "type": "blob", "content": "node_modules/\n*.log\n", }, - map[string]interface{}{ + map[string]any{ "path": "src/main.js", "mode": "100644", "type": "blob", @@ -2077,10 +2621,10 @@ func Test_PushFiles(t *testing.T) { // Create commit with all user files WithRequestMatchHandler( PostReposGitCommitsByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ + expectRequestBody(t, map[string]any{ "message": "Initial project setup", "tree": "ghi789", - "parents": []interface{}{"init456"}, + "parents": []any{"init456"}, }).andThen( mockResponse(t, http.StatusCreated, mockNewCommit), ), @@ -2088,7 +2632,7 @@ func Test_PushFiles(t *testing.T) { // Update reference WithRequestMatchHandler( PatchReposGitRefsByOwnerByRepoByRef, - expectRequestBody(t, map[string]interface{}{ + expectRequestBody(t, map[string]any{ "sha": "jkl012", "force": false, }).andThen( @@ -2096,20 +2640,20 @@ func Test_PushFiles(t *testing.T) { ), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "branch": "main", - "files": []interface{}{ - map[string]interface{}{ + "files": []any{ + map[string]any{ "path": "README.md", "content": "# Project\n\nProject README", }, - map[string]interface{}{ + map[string]any{ "path": ".gitignore", "content": "node_modules/\n*.log\n", }, - map[string]interface{}{ + map[string]any{ "path": "src/main.js", "content": "console.log('Hello World');\n", }, @@ -2128,7 +2672,7 @@ func Test_PushFiles(t *testing.T) { http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusConflict) - response := map[string]interface{}{ + response := map[string]any{ "message": "Git Repository is empty.", } _ = json.NewEncoder(w).Encode(response) @@ -2147,12 +2691,12 @@ func Test_PushFiles(t *testing.T) { mockResponse(t, http.StatusInternalServerError, nil), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "branch": "main", - "files": []interface{}{ - map[string]interface{}{ + "files": []any{ + map[string]any{ "path": "README.md", "content": "# README", }, @@ -2176,7 +2720,7 @@ func Test_PushFiles(t *testing.T) { // First call: returns 409 Conflict for empty repo w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusConflict) - response := map[string]interface{}{ + response := map[string]any{ "message": "Git Repository is empty.", } _ = json.NewEncoder(w).Encode(response) @@ -2203,12 +2747,12 @@ func Test_PushFiles(t *testing.T) { }, ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "branch": "main", - "files": []interface{}{ - map[string]interface{}{ + "files": []any{ + map[string]any{ "path": "README.md", "content": "# README", }, @@ -2227,7 +2771,7 @@ func Test_PushFiles(t *testing.T) { http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusConflict) - response := map[string]interface{}{ + response := map[string]any{ "message": "Git Repository is empty.", } _ = json.NewEncoder(w).Encode(response) @@ -2254,16 +2798,16 @@ func Test_PushFiles(t *testing.T) { mockResponse(t, http.StatusInternalServerError, nil), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "branch": "main", - "files": []interface{}{ - map[string]interface{}{ + "files": []any{ + map[string]any{ "path": "README.md", "content": "# README", }, - map[string]interface{}{ + map[string]any{ "path": "LICENSE", "content": "MIT", }, @@ -2278,7 +2822,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, } @@ -2356,14 +2900,14 @@ func Test_ListBranches(t *testing.T) { // Test cases tests := []struct { name string - args map[string]interface{} + args map[string]any mockResponses []MockBackendOption wantErr bool errContains string }{ { name: "success", - args: map[string]interface{}{ + args: map[string]any{ "owner": "owner", "repo": "repo", "page": float64(2), @@ -2378,7 +2922,7 @@ func Test_ListBranches(t *testing.T) { }, { name: "missing owner", - args: map[string]interface{}{ + args: map[string]any{ "repo": "repo", }, mockResponses: []MockBackendOption{}, @@ -2387,7 +2931,7 @@ func Test_ListBranches(t *testing.T) { }, { name: "missing repo", - args: map[string]interface{}{ + args: map[string]any{ "owner": "owner", }, mockResponses: []MockBackendOption{}, @@ -2399,7 +2943,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, } @@ -2489,7 +3033,7 @@ func Test_DeleteFile(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedCommitSHA string expectedErrMsg string @@ -2510,10 +3054,10 @@ func Test_DeleteFile(t *testing.T) { // Create tree WithRequestMatchHandler( PostReposGitTreesByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ + expectRequestBody(t, map[string]any{ "base_tree": "def456", - "tree": []interface{}{ - map[string]interface{}{ + "tree": []any{ + map[string]any{ "path": "docs/example.md", "mode": "100644", "type": "blob", @@ -2527,10 +3071,10 @@ func Test_DeleteFile(t *testing.T) { // Create commit WithRequestMatchHandler( PostReposGitCommitsByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ + expectRequestBody(t, map[string]any{ "message": "Delete example file", "tree": "ghi789", - "parents": []interface{}{"abc123"}, + "parents": []any{"abc123"}, }).andThen( mockResponse(t, http.StatusCreated, mockNewCommit), ), @@ -2538,7 +3082,7 @@ func Test_DeleteFile(t *testing.T) { // Update reference WithRequestMatchHandler( PatchReposGitRefsByOwnerByRepoByRef, - expectRequestBody(t, map[string]interface{}{ + expectRequestBody(t, map[string]any{ "sha": "jkl012", "force": false, }).andThen( @@ -2551,7 +3095,7 @@ func Test_DeleteFile(t *testing.T) { ), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "docs/example.md", @@ -2572,7 +3116,7 @@ func Test_DeleteFile(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "docs/nonexistent.md", @@ -2587,7 +3131,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, } @@ -2612,12 +3156,12 @@ func Test_DeleteFile(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var response map[string]interface{} + var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) // Verify the response contains the expected commit - commit, ok := response["commit"].(map[string]interface{}) + commit, ok := response["commit"].(map[string]any) require.True(t, ok) commitSHA, ok := commit["sha"].(string) require.True(t, ok) @@ -2666,7 +3210,7 @@ func Test_ListTags(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedTags []*github.RepositoryTag expectedErrMsg string @@ -2684,7 +3228,7 @@ func Test_ListTags(t *testing.T) { ), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -2702,7 +3246,7 @@ func Test_ListTags(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -2714,7 +3258,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, } @@ -2742,15 +3286,15 @@ func Test_ListTags(t *testing.T) { textContent := getTextResult(t, result) // Parse and verify the result - var returnedTags []*github.RepositoryTag + var returnedTags []MinimalTag err = json.Unmarshal([]byte(textContent.Text), &returnedTags) require.NoError(t, err) // Verify each tag require.Equal(t, len(tc.expectedTags), len(returnedTags)) for i, expectedTag := range tc.expectedTags { - assert.Equal(t, *expectedTag.Name, *returnedTags[i].Name) - assert.Equal(t, *expectedTag.Commit.SHA, *returnedTags[i].Commit.SHA) + assert.Equal(t, *expectedTag.Name, returnedTags[i].Name) + assert.Equal(t, *expectedTag.Commit.SHA, returnedTags[i].SHA) } }) } @@ -2772,10 +3316,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"), }, } @@ -2792,9 +3345,10 @@ func Test_GetTag(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedTag *github.Tag + expectedRef *github.Reference expectedErrMsg string }{ { @@ -2806,7 +3360,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( @@ -2819,7 +3373,7 @@ func Test_GetTag(t *testing.T) { ), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "tag": "v1.0.0", @@ -2838,7 +3392,7 @@ func Test_GetTag(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "tag": "v1.0.0", @@ -2851,7 +3405,7 @@ func Test_GetTag(t *testing.T) { mockedClient: NewMockedHTTPClient( WithRequestMatch( GetReposGitRefByOwnerByRepoByRef, - mockTagRef, + mockAnnotatedTagRef, ), WithRequestMatchHandler( GetReposGitTagsByOwnerByRepoByTagSHA, @@ -2861,7 +3415,7 @@ func Test_GetTag(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "tag": "v1.0.0", @@ -2869,12 +3423,33 @@ func Test_GetTag(t *testing.T) { expectError: true, expectedErrMsg: "failed to get tag object", }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) + { + 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 := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2901,16 +3476,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.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.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.expectedRef.GetRef(), returnedRef.GetRef()) + assert.Equal(t, tc.expectedRef.Object.GetType(), returnedRef.Object.GetType()) + assert.Equal(t, tc.expectedRef.Object.GetSHA(), returnedRef.Object.GetSHA()) + } }) } } @@ -2945,7 +3533,7 @@ func Test_ListReleases(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedResult []*github.RepositoryRelease expectedErrMsg string @@ -2958,7 +3546,7 @@ func Test_ListReleases(t *testing.T) { mockReleases, ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -2976,7 +3564,7 @@ func Test_ListReleases(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -2987,7 +3575,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, } @@ -3003,16 +3591,17 @@ func Test_ListReleases(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - var returnedReleases []*github.RepositoryRelease + var returnedReleases []MinimalRelease err = json.Unmarshal([]byte(textContent.Text), &returnedReleases) require.NoError(t, err) assert.Len(t, returnedReleases, len(tc.expectedResult)) - for i, rel := range returnedReleases { - assert.Equal(t, *tc.expectedResult[i].TagName, *rel.TagName) + for i := range returnedReleases { + assert.Equal(t, *tc.expectedResult[i].TagName, returnedReleases[i].TagName) } }) } } + func Test_GetLatestRelease(t *testing.T) { serverTool := GetLatestRelease(translations.NullTranslationHelper) tool := serverTool.Tool @@ -3036,7 +3625,7 @@ func Test_GetLatestRelease(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedResult *github.RepositoryRelease expectedErrMsg string @@ -3049,7 +3638,7 @@ func Test_GetLatestRelease(t *testing.T) { mockRelease, ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -3067,7 +3656,7 @@ func Test_GetLatestRelease(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -3078,7 +3667,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, } @@ -3133,7 +3722,7 @@ func Test_GetReleaseByTag(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedResult *github.RepositoryRelease expectedErrMsg string @@ -3146,7 +3735,7 @@ func Test_GetReleaseByTag(t *testing.T) { mockRelease, ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "tag": "v1.0.0", @@ -3157,7 +3746,7 @@ func Test_GetReleaseByTag(t *testing.T) { { name: "missing owner parameter", mockedClient: NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "repo": "repo", "tag": "v1.0.0", }, @@ -3167,7 +3756,7 @@ func Test_GetReleaseByTag(t *testing.T) { { name: "missing repo parameter", mockedClient: NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "tag": "v1.0.0", }, @@ -3177,7 +3766,7 @@ func Test_GetReleaseByTag(t *testing.T) { { name: "missing tag parameter", mockedClient: NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -3195,7 +3784,7 @@ func Test_GetReleaseByTag(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "tag": "v999.0.0", @@ -3214,7 +3803,7 @@ func Test_GetReleaseByTag(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "tag": "v1.0.0", @@ -3226,7 +3815,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, } @@ -3273,6 +3862,107 @@ func Test_GetReleaseByTag(t *testing.T) { } } +// Test_GetReleaseByTag_IFC_FeatureFlag verifies the IFC label on +// get_release_by_tag. The label is only present when the ifc_labels flag is +// enabled, and confidentiality is public only for a non-draft release on a +// public repo. A draft release is visible only to push-access users, so even +// on a public repo it must be labeled private. Guards against the same +// under-classification fixed for repository security advisories. +func Test_GetReleaseByTag_IFC_FeatureFlag(t *testing.T) { + t.Parallel() + + serverTool := GetReleaseByTag(translations.NullTranslationHelper) + + makeRelease := func(draft bool) *github.RepositoryRelease { + return &github.RepositoryRelease{ + ID: github.Ptr(int64(1)), + TagName: github.Ptr("v1.0.0"), + Name: github.Ptr("v1.0.0"), + Draft: github.Ptr(draft), + } + } + + makeMockClient := func(isPrivate bool, release *github.RepositoryRelease) *http.Client { + return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposReleasesTagsByOwnerByRepoByTag: mockResponse(t, http.StatusOK, release), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{ + "name": "repo", + "private": isPrivate, + }), + }) + } + + reqParams := map[string]any{"owner": "owner", "repo": "repo", "tag": "v1.0.0"} + + readIFC := func(t *testing.T, result *mcp.CallToolResult) (map[string]any, bool) { + t.Helper() + if result.Meta == nil { + return nil, false + } + label, ok := result.Meta["ifc"] + if !ok { + return nil, false + } + labelJSON, err := json.Marshal(label) + require.NoError(t, err) + var labelMap map[string]any + require.NoError(t, json.Unmarshal(labelJSON, &labelMap)) + return labelMap, true + } + + t.Run("feature flag disabled omits ifc label", func(t *testing.T) { + t.Parallel() + deps := BaseDeps{Client: mustNewGHClient(t, makeMockClient(false, makeRelease(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) + }) + + t.Run("public repo with published release is public", func(t *testing.T) { + t.Parallel() + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false, makeRelease(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) + + label, ok := readIFC(t, result) + require.True(t, ok) + assert.Equal(t, "trusted", label["integrity"]) + assert.Equal(t, "public", label["confidentiality"]) + }) + + t.Run("public repo with draft release is private", func(t *testing.T) { + t.Parallel() + // Reviewer-class scenario: a draft release on a public repo is not + // world-readable, so the label must not be public. + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false, makeRelease(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) + + label, ok := readIFC(t, result) + require.True(t, ok) + assert.Equal(t, "trusted", label["integrity"]) + assert.Equal(t, "private", label["confidentiality"], "draft release on public repo must be private") + }) +} + func Test_looksLikeSHA(t *testing.T) { tests := []struct { name string @@ -3671,7 +4361,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 { @@ -3760,7 +4450,7 @@ func Test_ListStarredRepositories(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedErrMsg string expectedCount int @@ -3776,7 +4466,7 @@ func Test_ListStarredRepositories(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{}, + requestArgs: map[string]any{}, expectError: false, expectedCount: 2, }, @@ -3791,7 +4481,7 @@ func Test_ListStarredRepositories(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "username": "testuser", }, expectError: false, @@ -3808,7 +4498,7 @@ func Test_ListStarredRepositories(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{}, + requestArgs: map[string]any{}, expectError: true, expectedErrMsg: "failed to list starred repositories", }, @@ -3817,7 +4507,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, } @@ -3875,7 +4565,7 @@ func Test_StarRepository(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedErrMsg string }{ @@ -3889,7 +4579,7 @@ func Test_StarRepository(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "testowner", "repo": "testrepo", }, @@ -3906,7 +4596,7 @@ func Test_StarRepository(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "testowner", "repo": "nonexistent", }, @@ -3918,7 +4608,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, } @@ -3966,7 +4656,7 @@ func Test_UnstarRepository(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedErrMsg string }{ @@ -3980,7 +4670,7 @@ func Test_UnstarRepository(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "testowner", "repo": "testrepo", }, @@ -3997,7 +4687,7 @@ func Test_UnstarRepository(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "testowner", "repo": "nonexistent", }, @@ -4009,7 +4699,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, } @@ -4038,3 +4728,812 @@ func Test_UnstarRepository(t *testing.T) { }) } } +func Test_GetFileBlame(t *testing.T) { + // Verify tool definition once + serverTool := GetFileBlame(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + // get_file_blame is gated so it is not advertised unless the feature flag + // (or insiders mode) opts it in. + assert.Equal(t, FeatureFlagFileBlame, serverTool.FeatureFlagEnable, "get_file_blame must be gated behind the file_blame feature flag") + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Equal(t, "get_file_blame", tool.Name) + assert.NotEmpty(t, tool.Description) + for _, key := range []string{"owner", "repo", "path", "ref", "start_line", "end_line", "perPage", "after"} { + assert.Contains(t, schema.Properties, key, "schema missing property %q", key) + } + assert.NotContains(t, schema.Properties, "page") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "path"}) + require.NotNil(t, tool.Annotations) + assert.True(t, tool.Annotations.ReadOnlyHint, "blame is read-only") + + // blameQueryShape is the GraphQL query shape used by all + // network-touching subtests below. Defined once so changes to the wire + // schema are made in a single place. + type blameQueryShape = struct { + Repository struct { + DefaultBranchRef struct { + Name githubv4.String + } + Object struct { + Typename githubv4.String `graphql:"__typename"` + Commit blameCommitFragment `graphql:"... on Commit"` + Tag struct { + Target struct { + Typename githubv4.String `graphql:"__typename"` + Commit blameCommitFragment `graphql:"... on Commit"` + } + } `graphql:"... on Tag"` + } `graphql:"object(expression: $ref)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + makeBlameVars := func(owner, repo, ref, path string) map[string]any { + return map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "ref": githubv4.String(ref), + "path": githubv4.String(path), + } + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + validateResponse func(t *testing.T, result string) + }{ + { + name: "successful blame using default branch (HEAD)", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "HEAD", "README.md"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Commit", + "blame": map[string]any{ + "ranges": []map[string]any{ + { + "startingLine": 1, "endingLine": 5, "age": 2, + "commit": map[string]any{ + "oid": "abc123def456", + "message": "Initial commit\n\nLong body that should not appear in the response.", + "committedDate": "2024-01-01T12:00:00Z", + "author": map[string]any{ + "name": "John Doe", "email": "john@example.com", + "user": map[string]any{"login": "johndoe", "url": "https://github.com/johndoe"}, + }, + }, + }, + { + // Same commit as the first range -> must be deduplicated. + "startingLine": 6, "endingLine": 7, "age": 2, + "commit": map[string]any{ + "oid": "abc123def456", + "message": "Initial commit\n\nLong body that should not appear in the response.", + "committedDate": "2024-01-01T12:00:00Z", + "author": map[string]any{ + "name": "John Doe", "email": "john@example.com", + "user": map[string]any{"login": "johndoe", "url": "https://github.com/johndoe"}, + }, + }, + }, + { + "startingLine": 8, "endingLine": 10, "age": 1, + "commit": map[string]any{ + "oid": "def456ghi789", + "message": "Update README", + "committedDate": "2024-01-02T15:30:00Z", + "author": map[string]any{ + "name": "Jane Smith", "email": "jane@example.com", + "user": map[string]any{"login": "janesmith", "url": "https://github.com/janesmith"}, + }, + }, + }, + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "README.md", + }, + validateResponse: func(t *testing.T, result string) { + var br BlameResult + require.NoError(t, json.Unmarshal([]byte(result), &br)) + assert.Equal(t, "testowner/testrepo", br.Repository) + assert.Equal(t, "README.md", br.Path) + assert.Equal(t, "main", br.Ref, "ref should resolve to default branch name") + assert.False(t, br.Truncated) + assert.Equal(t, 3, br.TotalRanges) + assert.False(t, br.PageInfo.HasNextPage) + assert.False(t, br.PageInfo.HasPreviousPage) + assert.NotEmpty(t, br.PageInfo.StartCursor) + assert.NotEmpty(t, br.PageInfo.EndCursor) + require.Len(t, br.Ranges, 3) + // Commits map is deduplicated. + require.Len(t, br.Commits, 2) + require.Contains(t, br.Commits, "abc123def456") + require.Contains(t, br.Commits, "def456ghi789") + // Multi-line message must be reduced to its headline. + assert.Equal(t, "Initial commit", br.Commits["abc123def456"].MessageHeadline) + assert.NotContains(t, result, "Long body that should not appear") + // Login/URL pointers populated. + require.NotNil(t, br.Commits["abc123def456"].Author.Login) + assert.Equal(t, "johndoe", *br.Commits["abc123def456"].Author.Login) + }, + }, + { + name: "successful blame with explicit ref", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "feature-branch", "src/main.go"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Commit", + "blame": map[string]any{ + "ranges": []map[string]any{ + { + "startingLine": 1, "endingLine": 3, "age": 1, + "commit": map[string]any{ + "oid": "xyz789abc123", + "message": "Add main function", + "committedDate": "2024-01-03T10:00:00Z", + "author": map[string]any{ + "name": "Bob Developer", "email": "bob@example.com", + "user": nil, + }, + }, + }, + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "src/main.go", + "ref": "feature-branch", + }, + validateResponse: func(t *testing.T, result string) { + var br BlameResult + require.NoError(t, json.Unmarshal([]byte(result), &br)) + assert.Equal(t, "feature-branch", br.Ref, "explicit ref echoed back") + require.Len(t, br.Ranges, 1) + require.Contains(t, br.Commits, "xyz789abc123") + assert.Nil(t, br.Commits["xyz789abc123"].Author.Login, "anonymous author has no login") + }, + }, + { + name: "successful blame with annotated tag ref", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "v1.0.0", "src/tagged.go"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Tag", + "target": map[string]any{ + "__typename": "Commit", + "blame": map[string]any{ + "ranges": []map[string]any{ + { + "startingLine": 1, "endingLine": 2, "age": 1, + "commit": map[string]any{ + "oid": "taggedcommit123", + "message": "Tagged release commit", + "committedDate": "2024-01-04T10:00:00Z", + "author": map[string]any{"name": "Tag Author", "email": "tag@example.com", "user": nil}, + }, + }, + }, + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "src/tagged.go", + "ref": "v1.0.0", + }, + validateResponse: func(t *testing.T, result string) { + var br BlameResult + require.NoError(t, json.Unmarshal([]byte(result), &br)) + assert.Equal(t, "v1.0.0", br.Ref, "explicit annotated tag ref echoed back") + require.Len(t, br.Ranges, 1) + assert.Equal(t, "taggedcommit123", br.Ranges[0].CommitSHA) + require.Contains(t, br.Commits, "taggedcommit123") + assert.Equal(t, "Tagged release commit", br.Commits["taggedcommit123"].MessageHeadline, + "commit metadata threads through the Tag.Target.Commit path") + assert.Equal(t, "Tag Author", br.Commits["taggedcommit123"].Author.Name) + }, + }, + { + name: "empty blame ranges", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "HEAD", "EMPTY.md"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Commit", + "blame": map[string]any{"ranges": []map[string]any{}}, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "EMPTY.md", + }, + validateResponse: func(t *testing.T, result string) { + var br BlameResult + require.NoError(t, json.Unmarshal([]byte(result), &br)) + assert.Equal(t, 0, br.TotalRanges) + assert.Empty(t, br.Ranges) + assert.Empty(t, br.Commits) + assert.False(t, br.PageInfo.HasNextPage) + assert.False(t, br.PageInfo.HasPreviousPage) + assert.False(t, br.Truncated) + // Ranges should marshal as an empty array, not null. + assert.Contains(t, result, `"ranges":[]`) + }, + }, + { + name: "ref resolves to non-commit object", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "main", "docs"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Tree", + "blame": map[string]any{"ranges": []map[string]any{}}, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "docs", + "ref": "main", + }, + expectError: true, + expectedErrMsg: "did not resolve to a commit", + }, + { + name: "ref not found", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "no-such-ref", "README.md"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": nil, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "README.md", + "ref": "no-such-ref", + }, + expectError: true, + expectedErrMsg: "was not found", + }, + { + name: "annotated tag target is not commit", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "tree-tag", "README.md"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Tag", + "target": map[string]any{"__typename": "Tree"}, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "README.md", + "ref": "tree-tag", + }, + expectError: true, + expectedErrMsg: "tag target did not resolve to a commit", + }, + { + name: "line-range filter clamps and drops ranges", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "HEAD", "src/big.go"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Commit", + "blame": map[string]any{ + "ranges": []map[string]any{ + { + "startingLine": 1, "endingLine": 5, "age": 1, + "commit": map[string]any{ + "oid": "sha-A", "message": "A", "committedDate": "2024-01-01T00:00:00Z", + "author": map[string]any{"name": "a", "email": "a@x", "user": nil}, + }, + }, + { + "startingLine": 6, "endingLine": 12, "age": 1, + "commit": map[string]any{ + "oid": "sha-B", "message": "B", "committedDate": "2024-01-01T00:00:00Z", + "author": map[string]any{"name": "b", "email": "b@x", "user": nil}, + }, + }, + { + "startingLine": 13, "endingLine": 20, "age": 1, + "commit": map[string]any{ + "oid": "sha-C", "message": "C", "committedDate": "2024-01-01T00:00:00Z", + "author": map[string]any{"name": "c", "email": "c@x", "user": nil}, + }, + }, + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "src/big.go", + "start_line": float64(8), + "end_line": float64(15), + }, + validateResponse: func(t *testing.T, result string) { + var br BlameResult + require.NoError(t, json.Unmarshal([]byte(result), &br)) + // First range (1-5) is dropped; middle clamped to 8-12; + // last clamped to 13-15. + require.Len(t, br.Ranges, 2) + assert.Equal(t, 8, br.Ranges[0].StartingLine) + assert.Equal(t, 12, br.Ranges[0].EndingLine) + assert.Equal(t, "sha-B", br.Ranges[0].CommitSHA) + assert.Equal(t, 13, br.Ranges[1].StartingLine) + assert.Equal(t, 15, br.Ranges[1].EndingLine) + assert.Equal(t, "sha-C", br.Ranges[1].CommitSHA) + assert.NotContains(t, br.Commits, "sha-A", "filtered-out commit must not appear") + }, + }, + { + name: "cursor pagination returns requested page", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "HEAD", "src/paged.go"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Commit", + "blame": map[string]any{ + "ranges": []map[string]any{ + { + "startingLine": 1, "endingLine": 1, "age": 1, + "commit": map[string]any{ + "oid": "sha-A", "message": "A", "committedDate": "2024-01-01T00:00:00Z", + "author": map[string]any{"name": "a", "email": "a@x", "user": nil}, + }, + }, + { + "startingLine": 2, "endingLine": 2, "age": 1, + "commit": map[string]any{ + "oid": "sha-B", "message": "B", "committedDate": "2024-01-01T00:00:00Z", + "author": map[string]any{"name": "b", "email": "b@x", "user": nil}, + }, + }, + { + "startingLine": 3, "endingLine": 3, "age": 1, + "commit": map[string]any{ + "oid": "sha-C", "message": "C", "committedDate": "2024-01-01T00:00:00Z", + "author": map[string]any{"name": "c", "email": "c@x", "user": nil}, + }, + }, + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "src/paged.go", + "perPage": float64(1), + "after": encodeBlameCursor(1), + }, + validateResponse: func(t *testing.T, result string) { + var br BlameResult + require.NoError(t, json.Unmarshal([]byte(result), &br)) + assert.Equal(t, 3, br.TotalRanges) + require.Len(t, br.Ranges, 1) + assert.Equal(t, "sha-B", br.Ranges[0].CommitSHA) + require.Len(t, br.Commits, 1) + require.Contains(t, br.Commits, "sha-B") + assert.True(t, br.PageInfo.HasNextPage) + assert.True(t, br.PageInfo.HasPreviousPage) + assert.Equal(t, encodeBlameCursor(1), br.PageInfo.StartCursor) + assert.Equal(t, encodeBlameCursor(2), br.PageInfo.EndCursor) + }, + }, + { + name: "GraphQL error is surfaced", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "main", "nonexistent.txt"), + githubv4mock.ErrorResponse("file not found"), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "nonexistent.txt", + "ref": "main", + }, + expectError: true, + expectedErrMsg: "file not found", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := githubv4.NewClient(tc.mockedClient) + deps := BaseDeps{GQLClient: 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) + if tc.validateResponse != nil { + tc.validateResponse(t, textContent.Text) + } + }) + } + + // Path validation must short-circuit before any network call. We supply + // a client with no matchers so any HTTP attempt would fail loudly. + t.Run("path validation rejects bad inputs", func(t *testing.T) { + client := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) + deps := BaseDeps{GQLClient: client} + handler := serverTool.Handler(deps) + + cases := []struct { + name string + path string + want string + }{ + {"empty", " ", "must not be empty"}, + {"absolute", "/etc/passwd", "must be relative"}, + {"traversal", "src/../../../etc/passwd", "must not contain '..'"}, + {"control char", "src/\x00bad.go", "control characters"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := createMCPRequest(map[string]any{ + "owner": "o", "repo": "r", "path": c.path, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, result.IsError, "expected validation error for %q", c.path) + assert.Contains(t, getErrorResult(t, result).Text, c.want) + }) + } + }) + + // Line-window and cursor pagination validation also short-circuits. + t.Run("line-range argument validation", func(t *testing.T) { + client := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) + deps := BaseDeps{GQLClient: client} + handler := serverTool.Handler(deps) + + cases := []struct { + name string + args map[string]any + want string + }{ + { + "end before start", + map[string]any{"owner": "o", "repo": "r", "path": "f.go", "start_line": float64(10), "end_line": float64(5)}, + "end_line must be >= start_line when both are provided", + }, + { + "start line zero", + map[string]any{"owner": "o", "repo": "r", "path": "f.go", "start_line": float64(0)}, + "start_line must be omitted or >= 1", + }, + { + "end line zero", + map[string]any{"owner": "o", "repo": "r", "path": "f.go", "end_line": float64(0)}, + "end_line must be omitted or >= 1", + }, + { + "page not supported", + map[string]any{"owner": "o", "repo": "r", "path": "f.go", "page": float64(1)}, + "cursor-based pagination", + }, + { + "invalid after cursor", + map[string]any{"owner": "o", "repo": "r", "path": "f.go", "after": "not-a-cursor"}, + "after cursor is invalid", + }, + { + "perPage too large", + map[string]any{"owner": "o", "repo": "r", "path": "f.go", "perPage": float64(101)}, + "perPage must be between 1 and 100 when provided", + }, + { + "perPage zero", + map[string]any{"owner": "o", "repo": "r", "path": "f.go", "perPage": float64(0)}, + "perPage must be between 1 and 100 when provided", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := createMCPRequest(c.args) + result, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, c.want) + }) + } + }) + + // Truncation: hand-build a response with > maxBlameRanges to verify + // the cap is applied and surfaced. + t.Run("truncation at maxBlameRanges", func(t *testing.T) { + ranges := make([]map[string]any, 0, maxBlameRanges+5) + for i := range maxBlameRanges + 5 { + ranges = append(ranges, map[string]any{ + "startingLine": i + 1, "endingLine": i + 1, "age": 0, + "commit": map[string]any{ + "oid": "sha-shared", + "message": "shared", + "committedDate": "2024-01-01T00:00:00Z", + "author": map[string]any{"name": "n", "email": "e@x", "user": nil}, + }, + }) + } + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("o", "r", "HEAD", "huge.txt"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Commit", + "blame": map[string]any{"ranges": ranges}, + }, + }, + }), + ), + ) + // Use a large perPage so the truncated set is observable on a + // single page. + req := createMCPRequest(map[string]any{ + "owner": "o", "repo": "r", "path": "huge.txt", "perPage": float64(100), + }) + client := githubv4.NewClient(mocked) + deps := BaseDeps{GQLClient: client} + handler := serverTool.Handler(deps) + result, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, result.IsError) + + var br BlameResult + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &br)) + assert.True(t, br.Truncated, "truncation flag must be set") + assert.Equal(t, maxBlameRanges+5, br.TotalRanges) + assert.Len(t, br.Ranges, 100, "perPage limits the page size") + assert.True(t, br.PageInfo.HasNextPage) + assert.NotEmpty(t, br.PageInfo.EndCursor) + }) +} + +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 + 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 28ce63b46c..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/v79/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/yosida95/uritemplate/v3" ) @@ -191,13 +191,14 @@ func RepositoryResourceContentsHandler(resourceURITemplate *uritemplate.Template } resp, err := rawClient.GetRawContent(ctx, owner, repo, path, rawOpts) + if err != nil { + return nil, fmt.Errorf("failed to get raw content: %w", err) + } defer func() { _ = resp.Body.Close() }() // If the raw content is not found, we will fall back to the GitHub API (in case it is a directory) switch { - case err != nil: - return nil, fmt.Errorf("failed to get raw content: %w", err) case resp.StatusCode == http.StatusOK: ext := filepath.Ext(path) mimeType := resp.Header.Get("Content-Type") @@ -257,3 +258,54 @@ func RepositoryResourceContentsHandler(resourceURITemplate *uritemplate.Template } } } + +// expandRepoResourceURI builds a resource URI using the appropriate URI template +// based on the provided parameters (sha, ref, or default). +func expandRepoResourceURI(owner, repo, sha, ref string, pathParts []string) (string, error) { + baseValues := uritemplate.Values{ + "owner": uritemplate.String(owner), + "repo": uritemplate.String(repo), + "path": uritemplate.List(pathParts...), + } + + switch { + case sha != "": + baseValues["sha"] = uritemplate.String(sha) + return repositoryResourceCommitContentURITemplate.Expand(baseValues) + + case ref != "": + // Parse ref to determine which template to use + switch { + case strings.HasPrefix(ref, "refs/heads/"): + branch := strings.TrimPrefix(ref, "refs/heads/") + baseValues["branch"] = uritemplate.String(branch) + return repositoryResourceBranchContentURITemplate.Expand(baseValues) + + case strings.HasPrefix(ref, "refs/tags/"): + tag := strings.TrimPrefix(ref, "refs/tags/") + baseValues["tag"] = uritemplate.String(tag) + return repositoryResourceTagContentURITemplate.Expand(baseValues) + + case strings.HasPrefix(ref, "refs/pull/") && strings.HasSuffix(ref, "/head"): + // Extract PR number from "refs/pull/{number}/head" + prPart := strings.TrimPrefix(ref, "refs/pull/") + prNumber := strings.TrimSuffix(prPart, "/head") + baseValues["prNumber"] = uritemplate.String(prNumber) + return repositoryResourcePrContentURITemplate.Expand(baseValues) + + case looksLikeSHA(ref): + // ref is actually a SHA (e.g., from resolveGitReference) + baseValues["sha"] = uritemplate.String(ref) + return repositoryResourceCommitContentURITemplate.Expand(baseValues) + + default: + // For other refs (like a branch name without refs/heads/ prefix), + // treat it as a branch + baseValues["branch"] = uritemplate.String(ref) + return repositoryResourceBranchContentURITemplate.Expand(baseValues) + } + + default: + return repositoryResourceContentURITemplate.Expand(baseValues) + } +} diff --git a/pkg/github/repository_resource_completions.go b/pkg/github/repository_resource_completions.go index c70cfe9488..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/v79/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 b6f83f3216..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/v79/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -257,7 +257,7 @@ func TestRepositoryResourceCompletionHandler_MaxResults(t *testing.T) { RepositoryResourceArgumentResolvers["owner"] = func(_ context.Context, _ *github.Client, _ map[string]string, _ string) ([]string, error) { // Return 150 results results := make([]string, 150) - for i := 0; i < 150; i++ { + for i := range 150 { results[i] = fmt.Sprintf("user%d", i) } return results, nil diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go index a3b3ca7545..cb57bae545 100644 --- a/pkg/github/repository_resource_test.go +++ b/pkg/github/repository_resource_test.go @@ -2,16 +2,25 @@ package github import ( "context" + "errors" "net/http" "net/url" "testing" "github.com/github/github-mcp-server/pkg/raw" - "github.com/google/go-github/v79/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) +// errorTransport is a http.RoundTripper that always returns an error. +type errorTransport struct { + err error +} + +func (t *errorTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, t.err +} + type resourceResponseType int const ( @@ -236,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, @@ -272,3 +282,34 @@ func Test_repositoryResourceContents(t *testing.T) { }) } } + +// Test_repositoryResourceContentsHandler_NetworkError tests that a network error +// during raw content fetch does not cause a panic (nil response body dereference). +func Test_repositoryResourceContentsHandler_NetworkError(t *testing.T) { + base, _ := url.Parse("https://raw.example.com/") + networkErr := errors.New("network error: connection refused") + + httpClient := &http.Client{Transport: &errorTransport{err: networkErr}} + client := mustNewGHClient(t, httpClient) + mockRawClient, err := raw.NewClient(client, base) + require.NoError(t, err) + deps := BaseDeps{ + Client: client, + RawClient: mockRawClient, + } + ctx := ContextWithDeps(context.Background(), deps) + + handler := RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) + + request := &mcp.ReadResourceRequest{ + Params: &mcp.ReadResourceParams{ + URI: "repo://owner/repo/contents/README.md", + }, + } + + // This should not panic, even though the HTTP client returns an error + resp, err := handler(ctx, request) + require.Error(t, err) + require.Nil(t, resp) + require.ErrorContains(t, err, "failed to get raw content") +} diff --git a/pkg/github/search.go b/pkg/github/search.go index 552fbfe781..23ccbd8387 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/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -161,11 +162,35 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo } } - return utils.NewToolResultText(string(r)), nil, nil + callResult := utils.NewToolResultText(string(r)) + attachSearchRepositoriesIFCLabel(ctx, deps, result.Repositories, callResult) + return callResult, nil, nil }, ) } +// attachSearchRepositoriesIFCLabel joins per-repository IFC labels across +// every matched repository and attaches the result to callResult when IFC +// labels are enabled. Visibility is read directly from the search response — +// no extra API call. The join math is shared with search_issues via +// ifc.LabelSearchIssues: public-only results stay public-untrusted, +// mixed-visibility results become private-untrusted, and all-private results +// become private-trusted. The +// feature-flag check is centralized here (mirroring the attach* helpers in +// ifc_labels.go) so the handler can call this unconditionally. +func attachSearchRepositoriesIFCLabel(ctx context.Context, deps ToolDependencies, repos []*github.Repository, callResult *mcp.CallToolResult) { + if callResult == nil || callResult.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + return + } + + visibilities := make([]bool, 0, len(repos)) + for _, repo := range repos { + visibilities = append(visibilities, repo.GetPrivate()) + } + + setIFCLabel(callResult, 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 +198,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 +245,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,12 +277,43 @@ 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 } - return utils.NewToolResultText(string(r)), nil, nil + callResult := utils.NewToolResultText(string(r)) + // Code search spans repositories; the IFC label is the conservative + // join across every matched repository's visibility, read directly + // from the search response. + visibilities := make([]bool, 0, len(result.CodeResults)) + for _, code := range result.CodeResults { + if code.Repository != nil { + visibilities = append(visibilities, code.Repository.GetPrivate()) + } + } + callResult = attachJoinedIFCLabel(ctx, deps, callResult, visibilities, ifc.LabelSearchIssues) + return callResult, nil, nil }, ) } @@ -344,7 +401,11 @@ func userOrOrgHandler(ctx context.Context, accountType string, deps ToolDependen if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + callResult := utils.NewToolResultText(string(r)) + // User and organization search returns public profile information that is + // authored by the account holders themselves, so it is public-untrusted. + callResult = attachStaticIFCLabel(ctx, deps, callResult, ifc.PublicUntrusted()) + return callResult, nil, nil } // SearchUsers creates a tool to search for GitHub users. @@ -430,3 +491,120 @@ 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 + } + + callResult := utils.NewToolResultText(string(r)) + // Commit search spans repositories; the IFC label is the conservative + // join across every matched repository's visibility, read directly + // from the search response. + visibilities := make([]bool, 0, len(result.Commits)) + for _, commit := range result.Commits { + if commit.Repository != nil { + visibilities = append(visibilities, commit.Repository.GetPrivate()) + } + } + callResult = attachJoinedIFCLabel(ctx, deps, callResult, visibilities, ifc.LabelSearchIssues) + return callResult, nil, nil + }, + ) +} diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index e15758c3e7..5ebf60842a 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/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -59,7 +60,7 @@ func Test_SearchRepositories(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedResult *github.RepositoriesSearchResult expectedErrMsg string @@ -77,7 +78,7 @@ func Test_SearchRepositories(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "golang test", "sort": "stars", "order": "desc", @@ -98,7 +99,7 @@ func Test_SearchRepositories(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "golang test", }, expectError: false, @@ -112,7 +113,7 @@ func Test_SearchRepositories(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Invalid query"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "invalid:query", }, expectError: true, @@ -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 mixed public and private 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,14 +304,14 @@ func Test_SearchRepositories_FullOutput(t *testing.T) { ), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) serverTool := SearchRepositories(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, } handler := serverTool.Handler(deps) - args := map[string]interface{}{ + args := map[string]any{ "query": "golang test", "minimal_output": false, } @@ -252,26 +362,39 @@ 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 - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedResult *github.CodeSearchResult expectedErrMsg string @@ -285,11 +408,11 @@ func Test_SearchCode(t *testing.T) { "order": "desc", "page": "1", "per_page": "30", - }).andThen( + }).withHeaders(textMatchAcceptHeader).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "fmt.Println language:go", "sort": "indexed", "order": "desc", @@ -306,11 +429,11 @@ 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), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "fmt.Println language:go", }, expectError: false, @@ -324,7 +447,7 @@ func Test_SearchCode(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "invalid:query", }, expectError: true, @@ -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(), + ) } }) } @@ -422,7 +551,7 @@ func Test_SearchUsers(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedResult *github.UsersSearchResult expectedErrMsg string @@ -440,7 +569,7 @@ func Test_SearchUsers(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "location:finland language:go", "sort": "followers", "order": "desc", @@ -461,7 +590,7 @@ func Test_SearchUsers(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "location:finland language:go", }, expectError: false, @@ -478,7 +607,7 @@ func Test_SearchUsers(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "type:user location:seattle followers:>100", }, expectError: false, @@ -495,7 +624,7 @@ func Test_SearchUsers(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "type:user (location:seattle OR location:california) followers:>50", }, expectError: false, @@ -509,7 +638,7 @@ func Test_SearchUsers(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "invalid:query", }, expectError: true, @@ -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, } @@ -608,7 +737,7 @@ func Test_SearchOrgs(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedResult *github.UsersSearchResult expectedErrMsg string @@ -624,7 +753,7 @@ func Test_SearchOrgs(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "github", }, expectError: false, @@ -641,7 +770,7 @@ func Test_SearchOrgs(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "type:org location:california followers:>1000", }, expectError: false, @@ -658,7 +787,7 @@ func Test_SearchOrgs(t *testing.T) { mockResponse(t, http.StatusOK, mockSearchResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", }, expectError: false, @@ -672,7 +801,7 @@ func Test_SearchOrgs(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "query": "invalid:query", }, expectError: true, @@ -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 1008200d19..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/v79/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 fa60021e53..18cfe73771 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.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/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -89,12 +90,47 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv return nil, nil, fmt.Errorf("failed to marshal alert: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Secret scanning alerts are access-restricted regardless of repo + // visibility and surface the matched secret material itself, so the + // label is always private-untrusted. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) + return result, nil, nil }, ) } 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 +140,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 +165,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), @@ -183,7 +205,12 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se return nil, nil, fmt.Errorf("failed to marshal alerts: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Secret scanning alerts are access-restricted regardless of repo + // visibility and surface the matched secret material itself, so the + // label is always private-untrusted. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) + return result, nil, nil }, ) } diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go index ed05d22150..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/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -40,7 +40,7 @@ func Test_GetSecretScanningAlert(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedAlert *github.SecretScanningAlert expectedErrMsg string @@ -50,7 +50,7 @@ func Test_GetSecretScanningAlert(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposSecretScanningAlertsByOwnerByRepoByAlertNumber: mockResponse(t, http.StatusOK, mockAlert), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "alertNumber": float64(42), @@ -66,7 +66,7 @@ func Test_GetSecretScanningAlert(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "alertNumber": float64(9999), @@ -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, } @@ -156,7 +156,7 @@ func Test_ListSecretScanningAlerts(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedAlerts []*github.SecretScanningAlert expectedErrMsg string @@ -165,12 +165,14 @@ 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}), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "state": "resolved", @@ -181,17 +183,39 @@ 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}), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, 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{ @@ -200,7 +224,7 @@ func Test_ListSecretScanningAlerts(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -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 7bdb978cdb..36e114c1dc 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.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/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -203,7 +204,12 @@ func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) inventor return nil, nil, fmt.Errorf("failed to marshal advisories: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Global advisories come from the world-readable GitHub Advisory + // Database (public) but contain externally authored prose + // (untrusted). + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGlobalSecurityAdvisory()) + return result, nil, nil }, ) } @@ -307,7 +313,17 @@ func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inve return nil, nil, fmt.Errorf("failed to marshal advisories: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Repository advisories carry externally authored prose (untrusted). + // Confidentiality follows repo visibility, but draft/triage/closed + // advisories are not world-readable even on a public repo, so the + // result is only public when every returned advisory is published. + allPublished := allAdvisoriesPublished(advisories) + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, + func(isPrivate bool) ifc.SecurityLabel { + return ifc.LabelRepositorySecurityAdvisory(isPrivate, allPublished) + }) + return result, nil, nil }, ) } @@ -364,7 +380,11 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S return nil, nil, fmt.Errorf("failed to marshal advisory: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // A global advisory is world-readable (public) but externally + // authored (untrusted). + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGlobalSecurityAdvisory()) + return result, nil, nil }, ) } @@ -459,7 +479,28 @@ func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) i return nil, nil, fmt.Errorf("failed to marshal advisories: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Org-wide advisory listings span the organization's repositories + // (including private ones) and are restricted to org members, so + // they are conservatively labeled private-untrusted (isPrivate=true, + // which forces private regardless of publication state). + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepositorySecurityAdvisory(true, false)) + return result, nil, nil }, ) } + +// allAdvisoriesPublished reports whether every advisory in the slice is in the +// "published" state. Repository security advisories can also be in draft, +// triage, or closed states, none of which are world-readable even on a public +// repository. An empty slice is treated as published (true) since there is no +// non-public content to protect. Used to decide whether a repository advisory +// listing may carry a public confidentiality label. +func allAdvisoriesPublished(advisories []*github.SecurityAdvisory) bool { + for _, advisory := range advisories { + if advisory.GetState() != "published" { + return false + } + } + return true +} diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go index bfc4c6985e..d02908610d 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -8,8 +8,9 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v79/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" "github.com/stretchr/testify/require" ) @@ -42,7 +43,7 @@ func Test_ListGlobalSecurityAdvisories(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedAdvisories []*github.GlobalSecurityAdvisory expectedErrMsg string @@ -52,7 +53,7 @@ func Test_ListGlobalSecurityAdvisories(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetAdvisories: mockResponse(t, http.StatusOK, []*github.GlobalSecurityAdvisory{mockAdvisory}), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "type": "reviewed", "ecosystem": "npm", "severity": "high", @@ -68,7 +69,7 @@ func Test_ListGlobalSecurityAdvisories(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Bad Request"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "type": "reviewed", "severity": "extreme", }, @@ -83,7 +84,7 @@ func Test_ListGlobalSecurityAdvisories(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) }), }), - requestArgs: map[string]interface{}{}, + requestArgs: map[string]any{}, expectError: true, expectedErrMsg: "failed to list global security advisories", }, @@ -92,7 +93,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) @@ -155,7 +156,7 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedAdvisory *github.GlobalSecurityAdvisory expectedErrMsg string @@ -165,7 +166,7 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetAdvisoriesByGhsaID: mockResponse(t, http.StatusOK, mockAdvisory), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "ghsaId": "GHSA-xxxx-xxxx-xxxx", }, expectError: false, @@ -179,7 +180,7 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Bad Request"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "ghsaId": "invalid-ghsa-id", }, expectError: true, @@ -193,7 +194,7 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "ghsaId": "GHSA-xxxx-xxxx-xxxx", }, expectError: true, @@ -204,7 +205,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) @@ -270,7 +271,7 @@ func Test_ListRepositorySecurityAdvisories(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedAdvisories []*github.SecurityAdvisory expectedErrMsg string @@ -285,7 +286,7 @@ func Test_ListRepositorySecurityAdvisories(t *testing.T) { mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1, adv2}), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -306,7 +307,7 @@ func Test_ListRepositorySecurityAdvisories(t *testing.T) { mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1}), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "octo", "repo": "hello-world", "direction": "desc", @@ -326,7 +327,7 @@ func Test_ListRepositorySecurityAdvisories(t *testing.T) { mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "Internal Server Error"}), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -337,7 +338,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) @@ -370,6 +371,132 @@ func Test_ListRepositorySecurityAdvisories(t *testing.T) { } } +// Test_ListRepositorySecurityAdvisories_IFC_FeatureFlag verifies the IFC label +// attached to list_repository_security_advisories. The label is only present +// when the ifc_labels feature flag is enabled, and — critically — confidentiality +// is public only when the repository is public AND every returned advisory is +// published. Draft/triage/closed advisories are not world-readable even on a +// public repo, so a result containing one must be labeled private. This guards +// against the under-classification raised in PR review. +func Test_ListRepositorySecurityAdvisories_IFC_FeatureFlag(t *testing.T) { + t.Parallel() + + toolDef := ListRepositorySecurityAdvisories(translations.NullTranslationHelper) + + publishedAdvisory := &github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-1111-1111-1111"), + Summary: github.Ptr("Published advisory"), + State: github.Ptr("published"), + } + draftAdvisory := &github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-2222-2222-2222"), + Summary: github.Ptr("Draft advisory"), + State: github.Ptr("draft"), + } + + makeMockClient := func(isPrivate bool, advisories []*github.SecurityAdvisory) *http.Client { + return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposSecurityAdvisoriesByOwnerByRepo: mockResponse(t, http.StatusOK, advisories), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{ + "name": "repo", + "private": isPrivate, + }), + }) + } + + reqParams := map[string]any{ + "owner": "owner", + "repo": "repo", + } + + readIFC := func(t *testing.T, result *mcp.CallToolResult) (map[string]any, bool) { + t.Helper() + if result.Meta == nil { + return nil, false + } + label, ok := result.Meta["ifc"] + if !ok { + return nil, false + } + labelJSON, err := json.Marshal(label) + require.NoError(t, err) + var labelMap map[string]any + require.NoError(t, json.Unmarshal(labelJSON, &labelMap)) + return labelMap, true + } + + t.Run("feature flag disabled omits ifc label", func(t *testing.T) { + t.Parallel() + deps := BaseDeps{Client: mustNewGHClient(t, makeMockClient(false, []*github.SecurityAdvisory{publishedAdvisory}))} + handler := toolDef.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 IFC labels are disabled") + }) + + t.Run("public repo with only published advisories is public", func(t *testing.T) { + t.Parallel() + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false, []*github.SecurityAdvisory{publishedAdvisory})), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + label, ok := readIFC(t, result) + require.True(t, ok, "result meta should contain ifc key") + assert.Equal(t, "untrusted", label["integrity"]) + assert.Equal(t, "public", label["confidentiality"]) + }) + + t.Run("public repo with a draft advisory is private", func(t *testing.T) { + t.Parallel() + // Reviewer scenario: a draft advisory on a public repo is not + // world-readable, so the label must not be public. + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false, []*github.SecurityAdvisory{publishedAdvisory, draftAdvisory})), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + label, ok := readIFC(t, result) + require.True(t, ok, "result meta should contain ifc key") + assert.Equal(t, "untrusted", label["integrity"]) + assert.Equal(t, "private", label["confidentiality"], "draft advisory on public repo must be private") + }) + + t.Run("private repo is private", func(t *testing.T) { + t.Parallel() + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(true, []*github.SecurityAdvisory{publishedAdvisory})), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + label, ok := readIFC(t, result) + require.True(t, ok, "result meta should contain ifc key") + assert.Equal(t, "untrusted", label["integrity"]) + assert.Equal(t, "private", label["confidentiality"]) + }) +} + func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { // Verify tool definition once toolDef := ListOrgRepositorySecurityAdvisories(translations.NullTranslationHelper) @@ -403,7 +530,7 @@ func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedAdvisories []*github.SecurityAdvisory expectedErrMsg string @@ -418,7 +545,7 @@ func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1, adv2}), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "org": "octo", }, expectError: false, @@ -438,7 +565,7 @@ func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1}), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "org": "octo", "direction": "asc", "sort": "created", @@ -457,7 +584,7 @@ func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"}), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "org": "octo", }, expectError: true, @@ -467,7 +594,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 8248da58fd..7ec5837c3a 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -3,433 +3,188 @@ package github import ( "context" "encoding/json" - "errors" "fmt" - "strconv" + "log/slog" "strings" + "time" + gherrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/octicons" + "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v79/github" - "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) -// NewServer creates a new GitHub MCP server with the specified GH client and logger. +type MCPServerConfig struct { + // Version of the server + Version string -func NewServer(version string, opts *mcp.ServerOptions) *mcp.Server { - if opts == nil { - opts = &mcp.ServerOptions{} - } + // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) + Host string - // Create a new MCP server - s := mcp.NewServer(&mcp.Implementation{ - Name: "github-mcp-server", - Title: "GitHub MCP Server", - Version: version, - Icons: octicons.Icons("mark-github"), - }, opts) + // GitHub Token to authenticate with the GitHub API + Token string - return s -} + // EnabledToolsets is a list of toolsets to enable + // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration + EnabledToolsets []string -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) { - switch req.Params.Ref.Type { - case "ref/resource": - if strings.HasPrefix(req.Params.Ref.URI, "repo://") { - return RepositoryResourceCompletionHandler(getClient)(ctx, req) - } - return nil, fmt.Errorf("unsupported resource URI: %s", req.Params.Ref.URI) - case "ref/prompt": - return nil, nil - default: - return nil, fmt.Errorf("unsupported ref type: %s", req.Params.Ref.Type) - } - } -} + // EnabledTools is a list of specific tools to enable (additive to toolsets) + // When specified, these tools are registered in addition to any specified toolset tools + EnabledTools []string -// OptionalParamOK is a helper function that can be used to fetch a requested parameter from the request. -// It returns the value, a boolean indicating if the parameter was present, and an error if the type is wrong. -func OptionalParamOK[T any, A map[string]any](args A, p string) (value T, ok bool, err error) { - // Check if the parameter is present in the request - val, exists := args[p] - if !exists { - // Not present, return zero value, false, no error - return - } + // EnabledFeatures is a list of feature flags that are enabled + // Items with FeatureFlagEnable matching an entry in this list will be available + EnabledFeatures []string - // Check if the parameter is of the expected type - value, ok = val.(T) - if !ok { - // Present but wrong type - err = fmt.Errorf("parameter %s is not of type %T, is %T", p, value, val) - ok = true // Set ok to true because the parameter *was* present, even if wrong type - return - } + // ReadOnly indicates if we should only offer read-only tools + ReadOnly bool - // Present and correct type - ok = true - return -} + // Translator provides translated text for the server tooling + Translator translations.TranslationHelperFunc -// isAcceptedError checks if the error is an accepted error. -func isAcceptedError(err error) bool { - var acceptedError *github.AcceptedError - return errors.As(err, &acceptedError) -} - -// RequiredParam is a helper function that can be used to fetch a requested parameter from the request. -// It does the following checks: -// 1. Checks if the parameter is present in the request. -// 2. Checks if the parameter is of the expected type. -// 3. Checks if the parameter is not empty, i.e: non-zero value -func RequiredParam[T comparable](args map[string]any, p string) (T, error) { - var zero T - - // Check if the parameter is present in the request - if _, ok := args[p]; !ok { - return zero, fmt.Errorf("missing required parameter: %s", p) - } + // Content window size + ContentWindowSize int - // Check if the parameter is of the expected type - val, ok := args[p].(T) - if !ok { - return zero, fmt.Errorf("parameter %s is not of type %T", p, zero) - } + // LockdownMode indicates if we should enable lockdown mode + LockdownMode bool - if val == zero { - return zero, fmt.Errorf("missing required parameter: %s", p) - } + // InsidersMode expands to the curated set of feature flags enabled for insiders. + InsidersMode bool - return val, nil -} + // Logger is used for logging within the server + Logger *slog.Logger + // RepoAccessTTL overrides the default TTL for repository access cache entries. + RepoAccessTTL *time.Duration -// RequiredInt is a helper function that can be used to fetch a requested parameter from the request. -// It does the following checks: -// 1. Checks if the parameter is present in the request. -// 2. Checks if the parameter is of the expected type. -// 3. Checks if the parameter is not empty, i.e: non-zero value -func RequiredInt(args map[string]any, p string) (int, error) { - v, err := RequiredParam[float64](args, p) - if err != nil { - return 0, err - } - return int(v), nil -} + // ExcludeTools is a list of tool names that should be disabled regardless of + // other configuration. These tools will be excluded even if their toolset is enabled + // or they are explicitly listed in EnabledTools. + ExcludeTools []string -// RequiredBigInt is a helper function that can be used to fetch a requested parameter from the request. -// It does the following checks: -// 1. Checks if the parameter is present in the request. -// 2. Checks if the parameter is of the expected type (float64). -// 3. Checks if the parameter is not empty, i.e: non-zero value. -// 4. Validates that the float64 value can be safely converted to int64 without truncation. -func RequiredBigInt(args map[string]any, p string) (int64, error) { - v, err := RequiredParam[float64](args, p) - if err != nil { - return 0, err - } + // TokenScopes contains the OAuth scopes available to the token. + // When non-nil, tools requiring scopes not in this list will be hidden. + // This is used for PAT scope filtering where we can't issue scope challenges. + TokenScopes []string - result := int64(v) - // Check if converting back produces the same value to avoid silent truncation - if float64(result) != v { - return 0, fmt.Errorf("parameter %s value %f is too large to fit in int64", p, v) - } - return result, nil + // Additional server options to apply + ServerOptions []MCPServerOption } -// OptionalParam is a helper function that can be used to fetch a requested parameter from the request. -// It does the following checks: -// 1. Checks if the parameter is present in the request, if not, it returns its zero-value -// 2. If it is present, it checks if the parameter is of the expected type and returns it -func OptionalParam[T any](args map[string]any, p string) (T, error) { - var zero T +type MCPServerOption func(*mcp.ServerOptions) - // Check if the parameter is present in the request - if _, ok := args[p]; !ok { - return zero, nil +func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependencies, inv *inventory.Inventory, middleware ...mcp.Middleware) (*mcp.Server, error) { + // Create the MCP server + serverOpts := &mcp.ServerOptions{ + Instructions: inv.Instructions(), + Logger: cfg.Logger, + CompletionHandler: CompletionsHandler(deps.GetClient), } - // Check if the parameter is of the expected type - if _, ok := args[p].(T); !ok { - return zero, fmt.Errorf("parameter %s is not of type %T, is %T", p, zero, args[p]) + // Apply any additional server options + for _, o := range cfg.ServerOptions { + o(serverOpts) } - return args[p].(T), nil -} + ghServer := NewServer(cfg.Version, cfg.Translator("SERVER_NAME", "github-mcp-server"), cfg.Translator("SERVER_TITLE", "GitHub MCP Server"), serverOpts) -// OptionalIntParam is a helper function that can be used to fetch a requested parameter from the request. -// It does the following checks: -// 1. Checks if the parameter is present in the request, if not, it returns its zero-value -// 2. If it is present, it checks if the parameter is of the expected type and returns it -func OptionalIntParam(args map[string]any, p string) (int, error) { - v, err := OptionalParam[float64](args, p) - if err != nil { - return 0, err - } - return int(v), nil -} + // 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, + // and any middleware that needs to read or modify the context should be before it. + ghServer.AddReceivingMiddleware(middleware...) + ghServer.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext) -// OptionalIntParamWithDefault is a helper function that can be used to fetch a requested parameter from the request -// similar to optionalIntParam, but it also takes a default value. -func OptionalIntParamWithDefault(args map[string]any, p string, d int) (int, error) { - v, err := OptionalIntParam(args, p) - if err != nil { - return 0, err + if unrecognized := inv.UnrecognizedToolsets(); len(unrecognized) > 0 { + cfg.Logger.Warn("Warning: unrecognized toolsets ignored", "toolsets", strings.Join(unrecognized, ", ")) } - if v == 0 { - return d, nil - } - return v, nil -} -// OptionalBoolParamWithDefault is a helper function that can be used to fetch a requested parameter from the request -// similar to optionalBoolParam, but it also takes a default value. -func OptionalBoolParamWithDefault(args map[string]any, p string, d bool) (bool, error) { - _, ok := args[p] - v, err := OptionalParam[bool](args, p) - if err != nil { - return false, err - } - if !ok { - return d, nil - } - return v, nil -} + // Register GitHub tools/resources/prompts from the inventory. + inv.RegisterAll(ctx, ghServer, deps) -// OptionalStringArrayParam is a helper function that can be used to fetch a requested parameter from the request. -// It does the following checks: -// 1. Checks if the parameter is present in the request, if not, it returns its zero-value -// 2. If it is present, iterates the elements and checks each is a string -func OptionalStringArrayParam(args map[string]any, p string) ([]string, error) { - // Check if the parameter is present in the request - if _, ok := args[p]; !ok { - return []string{}, nil + // 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, cfg.ReadOnly) } - switch v := args[p].(type) { - case nil: - return []string{}, nil - case []string: - return v, nil - case []any: - strSlice := make([]string, len(v)) - for i, v := range v { - s, ok := v.(string) - if !ok { - return []string{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v) - } - strSlice[i] = s - } - return strSlice, nil - default: - return []string{}, fmt.Errorf("parameter %s could not be coerced to []string, is %T", p, args[p]) - } + return ghServer, nil } -func convertStringSliceToBigIntSlice(s []string) ([]int64, error) { - int64Slice := make([]int64, len(s)) - for i, str := range s { - val, err := convertStringToBigInt(str, 0) - if err != nil { - return nil, fmt.Errorf("failed to convert element %d (%s) to int64: %w", i, str, err) - } - int64Slice[i] = val +// ResolvedEnabledToolsets determines which toolsets should be enabled based on config. +// Returns nil for "use defaults", empty slice for "none", or explicit list. +func ResolvedEnabledToolsets(enabledToolsets []string, enabledTools []string) []string { + if enabledToolsets != nil { + return enabledToolsets } - return int64Slice, nil -} - -func convertStringToBigInt(s string, def int64) (int64, error) { - v, err := strconv.ParseInt(s, 10, 64) - if err != nil { - return def, fmt.Errorf("failed to convert string %s to int64: %w", s, err) + 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 + return []string{} } - return v, nil -} -// OptionalBigIntArrayParam is a helper function that can be used to fetch a requested parameter from the request. -// It does the following checks: -// 1. Checks if the parameter is present in the request, if not, it returns an empty slice -// 2. If it is present, iterates the elements, checks each is a string, and converts them to int64 values -func OptionalBigIntArrayParam(args map[string]any, p string) ([]int64, error) { - // Check if the parameter is present in the request - if _, ok := args[p]; !ok { - return []int64{}, nil - } - - switch v := args[p].(type) { - case nil: - return []int64{}, nil - case []string: - return convertStringSliceToBigIntSlice(v) - case []any: - int64Slice := make([]int64, len(v)) - for i, v := range v { - s, ok := v.(string) - if !ok { - return []int64{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v) - } - val, err := convertStringToBigInt(s, 0) - if err != nil { - return []int64{}, fmt.Errorf("parameter %s: failed to convert element %d (%s) to int64: %w", p, i, s, err) - } - int64Slice[i] = val - } - return int64Slice, nil - default: - return []int64{}, fmt.Errorf("parameter %s could not be coerced to []int64, is %T", p, args[p]) - } -} - -// WithPagination adds REST API pagination parameters to a tool. -// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api -func WithPagination(schema *jsonschema.Schema) *jsonschema.Schema { - schema.Properties["page"] = &jsonschema.Schema{ - Type: "number", - Description: "Page number for pagination (min 1)", - Minimum: jsonschema.Ptr(1.0), - } - - schema.Properties["perPage"] = &jsonschema.Schema{ - Type: "number", - Description: "Results per page for pagination (min 1, max 100)", - Minimum: jsonschema.Ptr(1.0), - Maximum: jsonschema.Ptr(100.0), - } - - return schema + // nil means "use defaults" in WithToolsets + return nil } -// WithUnifiedPagination adds REST API pagination parameters to a tool. -// GraphQL tools will use this and convert page/perPage to GraphQL cursor parameters internally. -func WithUnifiedPagination(schema *jsonschema.Schema) *jsonschema.Schema { - schema.Properties["page"] = &jsonschema.Schema{ - Type: "number", - Description: "Page number for pagination (min 1)", - Minimum: jsonschema.Ptr(1.0), - } - - schema.Properties["perPage"] = &jsonschema.Schema{ - Type: "number", - Description: "Results per page for pagination (min 1, max 100)", - Minimum: jsonschema.Ptr(1.0), - Maximum: jsonschema.Ptr(100.0), - } - - schema.Properties["after"] = &jsonschema.Schema{ - Type: "string", - Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", +func addGitHubAPIErrorToContext(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, err error) { + // Ensure the context is cleared of any previous errors + // as context isn't propagated through middleware + ctx = gherrors.ContextWithGitHubErrors(ctx) + return next(ctx, method, req) } - - return schema } -// WithCursorPagination adds only cursor-based pagination parameters to a tool (no page parameter). -func WithCursorPagination(schema *jsonschema.Schema) *jsonschema.Schema { - schema.Properties["perPage"] = &jsonschema.Schema{ - Type: "number", - Description: "Results per page for pagination (min 1, max 100)", - Minimum: jsonschema.Ptr(1.0), - Maximum: jsonschema.Ptr(100.0), - } - - schema.Properties["after"] = &jsonschema.Schema{ - Type: "string", - Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", - } - - return schema -} - -type PaginationParams struct { - Page int - PerPage int - After string -} - -// OptionalPaginationParams returns the "page", "perPage", and "after" parameters from the request, -// or their default values if not present, "page" default is 1, "perPage" default is 30. -// In future, we may want to make the default values configurable, or even have this -// function returned from `withPagination`, where the defaults are provided alongside -// the min/max values. -func OptionalPaginationParams(args map[string]any) (PaginationParams, error) { - page, err := OptionalIntParamWithDefault(args, "page", 1) - if err != nil { - return PaginationParams{}, err - } - perPage, err := OptionalIntParamWithDefault(args, "perPage", 30) - if err != nil { - return PaginationParams{}, err - } - after, err := OptionalParam[string](args, "after") - if err != nil { - return PaginationParams{}, err - } - return PaginationParams{ - Page: page, - PerPage: perPage, - After: after, - }, nil -} - -// OptionalCursorPaginationParams returns the "perPage" and "after" parameters from the request, -// without the "page" parameter, suitable for cursor-based pagination only. -func OptionalCursorPaginationParams(args map[string]any) (CursorPaginationParams, error) { - perPage, err := OptionalIntParamWithDefault(args, "perPage", 30) - if err != nil { - return CursorPaginationParams{}, err - } - after, err := OptionalParam[string](args, "after") - if err != nil { - return CursorPaginationParams{}, err +// NewServer creates a new GitHub MCP server with the given version, server +// name, display title, and options. If name or title are empty the defaults +// "github-mcp-server" and "GitHub MCP Server" are used. +func NewServer(version, name, title string, opts *mcp.ServerOptions) *mcp.Server { + if opts == nil { + opts = &mcp.ServerOptions{} } - return CursorPaginationParams{ - PerPage: perPage, - After: after, - }, nil -} - -type CursorPaginationParams struct { - PerPage int - After string -} -// ToGraphQLParams converts cursor pagination parameters to GraphQL-specific parameters. -func (p CursorPaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) { - if p.PerPage > 100 { - return nil, fmt.Errorf("perPage value %d exceeds maximum of 100", p.PerPage) + if name == "" { + name = "github-mcp-server" } - if p.PerPage < 0 { - return nil, fmt.Errorf("perPage value %d cannot be negative", p.PerPage) + if title == "" { + title = "GitHub MCP Server" } - first := int32(p.PerPage) - var after *string - if p.After != "" { - after = &p.After - } - - return &GraphQLPaginationParams{ - First: &first, - After: after, - }, nil -} + // Create a new MCP server + s := mcp.NewServer(&mcp.Implementation{ + Name: name, + Title: title, + Version: version, + Icons: octicons.Icons("mark-github"), + }, opts) -type GraphQLPaginationParams struct { - First *int32 - After *string + return s } -// ToGraphQLParams converts REST API pagination parameters to GraphQL-specific parameters. -// This converts page/perPage to first parameter for GraphQL queries. -// If After is provided, it takes precedence over page-based pagination. -func (p PaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) { - // Convert to CursorPaginationParams and delegate to avoid duplication - cursor := CursorPaginationParams{ - PerPage: p.PerPage, - After: p.After, +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://") { + return RepositoryResourceCompletionHandler(getClient)(ctx, req) + } + return nil, fmt.Errorf("unsupported resource URI: %s", req.Params.Ref.URI) + case "ref/prompt": + return nil, nil + default: + return nil, fmt.Errorf("unsupported ref type: %s", req.Params.Ref.Type) + } } - return cursor.ToGraphQLParams() } func MarshalledTextResult(v any) *mcp.CallToolResult { diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index f4ae5f831e..7f909f431c 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -5,22 +5,28 @@ 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" - "github.com/google/go-github/v79/github" + gogithub "github.com/google/go-github/v87/github" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // stubDeps is a test helper that implements ToolDependencies with configurable behavior. // Use this when you need to test error paths or when you need closure-based client creation. type stubDeps struct { - clientFn func(context.Context) (*github.Client, error) + clientFn func(context.Context) (*gogithub.Client, error) gqlClientFn func(context.Context) (*githubv4.Client, error) rawClientFn func(context.Context) (*raw.Client, error) @@ -28,9 +34,10 @@ type stubDeps struct { t translations.TranslationHelperFunc flags FeatureFlags contentWindowSize int + obsv observability.Exporters } -func (s stubDeps) GetClient(ctx context.Context) (*github.Client, error) { +func (s stubDeps) GetClient(ctx context.Context) (*gogithub.Client, error) { if s.clientFn != nil { return s.clientFn(ctx) } @@ -51,21 +58,37 @@ func (s stubDeps) GetRawClient(ctx context.Context) (*raw.Client, error) { return nil, nil } -func (s stubDeps) GetRepoAccessCache() *lockdown.RepoAccessCache { return s.repoAccessCache } +func (s stubDeps) GetRepoAccessCache(_ context.Context) (*lockdown.RepoAccessCache, error) { + return s.repoAccessCache, nil +} func (s stubDeps) GetT() translations.TranslationHelperFunc { return s.t } -func (s stubDeps) GetFlags() FeatureFlags { return s.flags } +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) (*github.Client, error) { - return func(_ context.Context) (*github.Client, error) { - return github.NewClient(httpClient), nil + +// 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 mustNewGHClient(t, httpClient), nil } } -func stubClientFnErr(errMsg string) func(context.Context) (*github.Client, error) { - return func(_ context.Context) (*github.Client, error) { +func stubClientFnErr(errMsg string) func(context.Context) (*gogithub.Client, error) { + return func(_ context.Context) (*gogithub.Client, error) { return nil, errors.New(errMsg) } } @@ -76,21 +99,43 @@ 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"], } } func badRequestHandler(msg string) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { - structuredErrorResponse := github.ErrorResponse{ + structuredErrorResponse := gogithub.ErrorResponse{ Message: msg, } @@ -103,496 +148,205 @@ func badRequestHandler(msg string) http.HandlerFunc { } } -func Test_IsAcceptedError(t *testing.T) { - tests := []struct { - name string - err error - expectAccepted bool - }{ - { - name: "github AcceptedError", - err: &github.AcceptedError{}, - expectAccepted: true, - }, - { - name: "regular error", - err: fmt.Errorf("some other error"), - expectAccepted: false, - }, - { - name: "nil error", - err: nil, - expectAccepted: false, - }, - { - name: "wrapped AcceptedError", - err: fmt.Errorf("wrapped: %w", &github.AcceptedError{}), - expectAccepted: true, - }, +// TestNewMCPServer_CreatesSuccessfully verifies that the server can be created +// with the deps injection middleware properly configured. +func TestNewMCPServer_CreatesSuccessfully(t *testing.T) { + t.Parallel() + + // Create a minimal server configuration + cfg := MCPServerConfig{ + Version: "test", + Host: "", // defaults to github.com + Token: "test-token", + EnabledToolsets: []string{"context"}, + ReadOnly: false, + Translator: translations.NullTranslationHelper, + ContentWindowSize: 5000, + LockdownMode: false, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - result := isAcceptedError(tc.err) - assert.Equal(t, tc.expectAccepted, result) - }) - } + deps := stubDeps{obsv: stubExporters()} + + // Build inventory + inv, err := NewInventory(cfg.Translator). + WithDeprecatedAliases(DeprecatedToolAliases). + WithToolsets(cfg.EnabledToolsets). + Build() + + require.NoError(t, err, "expected inventory build to succeed") + + // Create the server + server, err := NewMCPServer(context.Background(), &cfg, deps, inv) + require.NoError(t, err, "expected server creation to succeed") + require.NotNil(t, server, "expected server to be non-nil") + + // The fact that the server was created successfully indicates that: + // 1. The deps injection middleware is properly added + // 2. Tools can be registered without panicking + // + // If the middleware wasn't properly added, tool calls would panic with + // "ToolDependencies not found in context" when executed. + // + // The actual middleware functionality and tool execution with ContextWithDeps + // is already tested in pkg/github/*_test.go. } -func Test_RequiredStringParam(t *testing.T) { - tests := []struct { - name string - params map[string]interface{} - paramName string - expected string - expectError bool - }{ - { - name: "valid string parameter", - params: map[string]interface{}{"name": "test-value"}, - paramName: "name", - expected: "test-value", - expectError: false, - }, - { - name: "missing parameter", - params: map[string]interface{}{}, - paramName: "name", - expected: "", - expectError: true, - }, - { - name: "empty string parameter", - params: map[string]interface{}{"name": ""}, - paramName: "name", - expected: "", - expectError: true, - }, - { - name: "wrong type parameter", - params: map[string]interface{}{"name": 123}, - paramName: "name", - expected: "", - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - result, err := RequiredParam[string](tc.params, tc.paramName) +// TestNewServer_NameAndTitleViaTranslation verifies that server name and title +// can be overridden via the translation helper (GITHUB_MCP_SERVER_NAME / +// GITHUB_MCP_SERVER_TITLE env vars or github-mcp-server-config.json) and +// fall back to sensible defaults when not overridden. +func TestNewServer_NameAndTitleViaTranslation(t *testing.T) { + t.Parallel() - if tc.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tc.expected, result) - } - }) - } -} - -func Test_OptionalStringParam(t *testing.T) { - tests := []struct { - name string - params map[string]interface{} - paramName string - expected string - expectError bool - }{ - { - name: "valid string parameter", - params: map[string]interface{}{"name": "test-value"}, - paramName: "name", - expected: "test-value", - expectError: false, - }, - { - name: "missing parameter", - params: map[string]interface{}{}, - paramName: "name", - expected: "", - expectError: false, - }, - { - name: "empty string parameter", - params: map[string]interface{}{"name": ""}, - paramName: "name", - expected: "", - expectError: false, - }, - { - name: "wrong type parameter", - params: map[string]interface{}{"name": 123}, - paramName: "name", - expected: "", - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - result, err := OptionalParam[string](tc.params, tc.paramName) - - if tc.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tc.expected, result) - } - }) - } -} - -func Test_RequiredInt(t *testing.T) { - tests := []struct { - name string - params map[string]interface{} - paramName string - expected int - expectError bool - }{ - { - name: "valid number parameter", - params: map[string]interface{}{"count": float64(42)}, - paramName: "count", - expected: 42, - expectError: false, - }, - { - name: "missing parameter", - params: map[string]interface{}{}, - paramName: "count", - expected: 0, - expectError: true, - }, - { - name: "wrong type parameter", - params: map[string]interface{}{"count": "not-a-number"}, - paramName: "count", - expected: 0, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - result, err := RequiredInt(tc.params, tc.paramName) - - if tc.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tc.expected, result) - } - }) - } -} -func Test_OptionalIntParam(t *testing.T) { tests := []struct { - name string - params map[string]interface{} - paramName string - expected int - expectError bool + name string + translator translations.TranslationHelperFunc + expectedName string + expectedTitle string }{ { - name: "valid number parameter", - params: map[string]interface{}{"count": float64(42)}, - paramName: "count", - expected: 42, - expectError: false, - }, - { - name: "missing parameter", - params: map[string]interface{}{}, - paramName: "count", - expected: 0, - expectError: false, - }, - { - name: "zero value", - params: map[string]interface{}{"count": float64(0)}, - paramName: "count", - expected: 0, - expectError: false, + name: "defaults when using NullTranslationHelper", + translator: translations.NullTranslationHelper, + expectedName: "github-mcp-server", + expectedTitle: "GitHub MCP Server", + }, + { + name: "custom name and title via translator", + translator: func(key, defaultValue string) string { + switch key { + case "SERVER_NAME": + return "my-github-server" + case "SERVER_TITLE": + return "My GitHub MCP Server" + default: + return defaultValue + } + }, + expectedName: "my-github-server", + expectedTitle: "My GitHub MCP Server", }, { - name: "wrong type parameter", - params: map[string]interface{}{"count": "not-a-number"}, - paramName: "count", - expected: 0, - expectError: true, + name: "custom name only via translator", + translator: func(key, defaultValue string) string { + if key == "SERVER_NAME" { + return "ghes-server" + } + return defaultValue + }, + expectedName: "ghes-server", + expectedTitle: "GitHub MCP Server", }, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - result, err := OptionalIntParam(tc.params, tc.paramName) - - if tc.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tc.expected, result) - } - }) - } -} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() -func Test_OptionalNumberParamWithDefault(t *testing.T) { - tests := []struct { - name string - params map[string]interface{} - paramName string - defaultVal int - expected int - expectError bool - }{ - { - name: "valid number parameter", - params: map[string]interface{}{"count": float64(42)}, - paramName: "count", - defaultVal: 10, - expected: 42, - expectError: false, - }, - { - name: "missing parameter", - params: map[string]interface{}{}, - paramName: "count", - defaultVal: 10, - expected: 10, - expectError: false, - }, - { - name: "zero value", - params: map[string]interface{}{"count": float64(0)}, - paramName: "count", - defaultVal: 10, - expected: 10, - expectError: false, - }, - { - name: "wrong type parameter", - params: map[string]interface{}{"count": "not-a-number"}, - paramName: "count", - defaultVal: 10, - expected: 0, - expectError: true, - }, - } + srv := NewServer("v1.0.0", tt.translator("SERVER_NAME", "github-mcp-server"), tt.translator("SERVER_TITLE", "GitHub MCP Server"), nil) + require.NotNil(t, srv) - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - result, err := OptionalIntParamWithDefault(tc.params, tc.paramName, tc.defaultVal) + // Connect a client to retrieve the initialize result and verify ServerInfo. + st, ct := mcp.NewInMemoryTransports() + client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil) - if tc.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tc.expected, result) + type clientResult struct { + result *mcp.InitializeResult + err error } + clientResultCh := make(chan clientResult, 1) + go func() { + cs, err := client.Connect(context.Background(), ct, nil) + if err != nil { + clientResultCh <- clientResult{err: err} + return + } + t.Cleanup(func() { _ = cs.Close() }) + clientResultCh <- clientResult{result: cs.InitializeResult()} + }() + + ss, err := srv.Connect(context.Background(), st, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = ss.Close() }) + + got := <-clientResultCh + require.NoError(t, got.err) + require.NotNil(t, got.result) + require.NotNil(t, got.result.ServerInfo) + assert.Equal(t, tt.expectedName, got.result.ServerInfo.Name) + assert.Equal(t, tt.expectedTitle, got.result.ServerInfo.Title) }) } } -func Test_OptionalBooleanParam(t *testing.T) { - tests := []struct { - name string - params map[string]interface{} - paramName string - expected bool - expectError bool - }{ - { - name: "true value", - params: map[string]interface{}{"flag": true}, - paramName: "flag", - expected: true, - expectError: false, - }, - { - name: "false value", - params: map[string]interface{}{"flag": false}, - paramName: "flag", - expected: false, - expectError: false, - }, - { - name: "missing parameter", - params: map[string]interface{}{}, - paramName: "flag", - expected: false, - expectError: false, - }, - { - name: "wrong type parameter", - params: map[string]interface{}{"flag": "not-a-boolean"}, - paramName: "flag", - expected: false, - expectError: true, - }, - } +// TestResolveEnabledToolsets verifies the toolset resolution logic. +func TestResolveEnabledToolsets(t *testing.T) { + t.Parallel() - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - result, err := OptionalParam[bool](tc.params, tc.paramName) - - if tc.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tc.expected, result) - } - }) - } -} - -func TestOptionalStringArrayParam(t *testing.T) { tests := []struct { - name string - params map[string]interface{} - paramName string - expected []string - expectError bool + name string + cfg MCPServerConfig + expectedResult []string }{ { - name: "parameter not in request", - params: map[string]any{}, - paramName: "flag", - expected: []string{}, - expectError: false, - }, - { - name: "valid any array parameter", - params: map[string]any{ - "flag": []any{"v1", "v2"}, + name: "nil toolsets and no tools - use defaults", + cfg: MCPServerConfig{ + EnabledToolsets: nil, + EnabledTools: nil, }, - paramName: "flag", - expected: []string{"v1", "v2"}, - expectError: false, + expectedResult: nil, // nil means "use defaults" }, { - name: "valid string array parameter", - params: map[string]any{ - "flag": []string{"v1", "v2"}, + name: "explicit toolsets", + cfg: MCPServerConfig{ + EnabledToolsets: []string{"repos", "issues"}, }, - paramName: "flag", - expected: []string{"v1", "v2"}, - expectError: false, + expectedResult: []string{"repos", "issues"}, }, { - name: "wrong type parameter", - params: map[string]any{ - "flag": 1, + name: "empty toolsets - disable all", + cfg: MCPServerConfig{ + EnabledToolsets: []string{}, }, - paramName: "flag", - expected: []string{}, - expectError: true, + expectedResult: []string{}, }, { - name: "wrong slice type parameter", - params: map[string]any{ - "flag": []any{"foo", 2}, + name: "specific tools without toolsets - no default toolsets", + cfg: MCPServerConfig{ + EnabledToolsets: nil, + EnabledTools: []string{"get_me"}, }, - paramName: "flag", - expected: []string{}, - expectError: true, + expectedResult: []string{}, // empty slice when tools specified but no toolsets }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result, err := OptionalStringArrayParam(tc.params, tc.paramName) - - if tc.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tc.expected, result) - } + result := ResolvedEnabledToolsets(tc.cfg.EnabledToolsets, tc.cfg.EnabledTools) + assert.Equal(t, tc.expectedResult, result) }) } } -func TestOptionalPaginationParams(t *testing.T) { +func TestCompletionsHandler_RejectsMissingRef(t *testing.T) { + getClient := func(_ context.Context) (*gogithub.Client, error) { + return &gogithub.Client{}, nil + } + handler := CompletionsHandler(getClient) + tests := []struct { - name string - params map[string]any - expected PaginationParams - expectError bool + name string + req *mcp.CompleteRequest }{ - { - name: "no pagination parameters, default values", - params: map[string]any{}, - expected: PaginationParams{ - Page: 1, - PerPage: 30, - }, - expectError: false, - }, - { - name: "page parameter, default perPage", - params: map[string]any{ - "page": float64(2), - }, - expected: PaginationParams{ - Page: 2, - PerPage: 30, - }, - expectError: false, - }, - { - name: "perPage parameter, default page", - params: map[string]any{ - "perPage": float64(50), - }, - expected: PaginationParams{ - Page: 1, - PerPage: 50, - }, - expectError: false, - }, - { - name: "page and perPage parameters", - params: map[string]any{ - "page": float64(2), - "perPage": float64(50), - }, - expected: PaginationParams{ - Page: 2, - PerPage: 50, - }, - expectError: false, - }, - { - name: "invalid page parameter", - params: map[string]any{ - "page": "not-a-number", - }, - expected: PaginationParams{}, - expectError: true, - }, - { - name: "invalid perPage parameter", - params: map[string]any{ - "perPage": "not-a-number", - }, - expected: PaginationParams{}, - expectError: true, - }, + {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 := OptionalPaginationParams(tc.params) - - if tc.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tc.expected, result) - } + 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 a169ff5910..2c894e5738 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -2,12 +2,14 @@ package github import ( "context" + "slices" "strings" + "github.com/google/go-github/v87/github" + "github.com/shurcooL/githubv4" + "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v79/github" - "github.com/shurcooL/githubv4" ) type GetClientFn func(context.Context) (*github.Client, error) @@ -75,6 +77,11 @@ var ( Description: "GitHub Actions workflows and CI/CD operations", Icon: "workflow", } + ToolsetMetadataCodeQuality = inventory.ToolsetMetadata{ + ID: "code_quality", + Description: "GitHub Code Quality related tools", + Icon: "code-square", + } ToolsetMetadataCodeSecurity = inventory.ToolsetMetadata{ ID: "code_security", Description: "Code security related tools, such as GitHub Code Scanning", @@ -122,24 +129,35 @@ 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", Icon: "tag", } - // Remote-only toolsets - these are only available in the remote MCP server - // but are documented here for consistency and to enable automated documentation. ToolsetMetadataCopilot = inventory.ToolsetMetadata{ ID: "copilot", Description: "Copilot related tools", + Default: true, 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{ ID: "copilot_spaces", Description: "Copilot Spaces tools", @@ -155,7 +173,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), @@ -166,7 +184,9 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GetFileContents(t), ListCommits(t), SearchCode(t), + SearchCommits(t), GetCommit(t), + GetFileBlame(t), ListBranches(t), ListTags(t), GetTag(t), @@ -182,6 +202,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ListStarredRepositories(t), StarRepository(t), UnstarRepository(t), + ListRepositoryCollaborators(t), // Git tools GetRepositoryTree(t), @@ -190,10 +211,12 @@ 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), - AssignCopilotToIssue(t), SubIssueWrite(t), // User tools @@ -210,9 +233,16 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { UpdatePullRequestBranch(t), CreatePullRequest(t), UpdatePullRequest(t), - RequestCopilotReview(t), PullRequestReviewWrite(t), AddCommentToPendingReview(t), + AddReplyToPullRequestComment(t), + + // Copilot tools + AssignCopilotToIssue(t), + RequestCopilotReview(t), + + // Code quality tools + GetCodeQualityFinding(t), // Code security tools GetCodeScanningAlert(t), @@ -238,24 +268,10 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ListDiscussions(t), GetDiscussion(t), GetDiscussionComments(t), + DiscussionCommentWrite(t), ListDiscussionCategories(t), // Actions tools - ListWorkflows(t), - ListWorkflowRuns(t), - GetWorkflowRun(t), - GetWorkflowRunLogs(t), - ListWorkflowJobs(t), - GetJobLogs(t), - ListWorkflowRunArtifacts(t), - DownloadWorkflowRunArtifact(t), - GetWorkflowRunUsage(t), - RunWorkflow(t), - RerunWorkflowRun(t), - RerunFailedJobs(t), - CancelWorkflowRun(t), - DeleteWorkflowRunLogs(t), - // Consolidated Actions tools (enabled via feature flag) ActionsList(t), ActionsGet(t), ActionsRunTrigger(t), @@ -274,17 +290,6 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { UpdateGist(t), // Project tools - ListProjects(t), - GetProject(t), - ListProjectFields(t), - GetProjectField(t), - ListProjectItems(t), - GetProjectItem(t), - AddProjectItem(t), - DeleteProjectItem(t), - UpdateProjectItem(t), - - // Consolidated project tools (enabled via feature flag) ProjectsList(t), ProjectsGet(t), ProjectsWrite(t), @@ -294,7 +299,37 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GetLabelForLabelsToolset(t), ListLabels(t), LabelWrite(t), - } + + // UI tools (insiders only) + UIGet(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. @@ -327,8 +362,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 := "" @@ -418,12 +453,7 @@ func RemoveToolset(tools []string, toRemove string) []string { } func ContainsToolset(tools []string, toCheck string) bool { - for _, tool := range tools { - if tool == toCheck { - return true - } - } - return false + return slices.Contains(tools, toCheck) } // CleanTools cleans tool names by removing duplicates and trimming whitespace. @@ -465,7 +495,6 @@ func GetDefaultToolsetIDs() []string { // in the local server. func RemoteOnlyToolsets() []inventory.ToolsetMetadata { return []inventory.ToolsetMetadata{ - ToolsetMetadataCopilot, ToolsetMetadataCopilotSpaces, ToolsetMetadataSupportSearch, } 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_test.go b/pkg/github/tools_test.go index 80270d2bce..2bcd2d5259 100644 --- a/pkg/github/tools_test.go +++ b/pkg/github/tools_test.go @@ -23,6 +23,7 @@ func TestAddDefaultToolset(t *testing.T) { input: []string{"default"}, expected: []string{ "context", + "copilot", "repos", "issues", "pull_requests", @@ -36,6 +37,7 @@ func TestAddDefaultToolset(t *testing.T) { "actions", "gists", "context", + "copilot", "repos", "issues", "pull_requests", @@ -47,6 +49,7 @@ func TestAddDefaultToolset(t *testing.T) { input: []string{"default", "context", "repos"}, expected: []string{ "context", + "copilot", "repos", "issues", "pull_requests", 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 bf2388a3d9..ba6659612a 100644 --- a/pkg/github/toolset_instructions.go +++ b/pkg/github/toolset_instructions.go @@ -39,6 +39,12 @@ 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: - Call list_project_fields first to understand available fields and get IDs/types before filtering. - Use EXACT returned field names (case-insensitive match). Don't invent names or IDs. diff --git a/pkg/github/ui_capability.go b/pkg/github/ui_capability.go new file mode 100644 index 0000000000..f237df8424 --- /dev/null +++ b/pkg/github/ui_capability.go @@ -0,0 +1,35 @@ +package github + +import ( + "context" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// mcpAppsExtensionKey is the capability extension key that clients use to +// advertise MCP Apps UI support. +const mcpAppsExtensionKey = "io.modelcontextprotocol/ui" + +// MCPAppMIMEType is the MIME type for MCP App UI resources. +const MCPAppMIMEType = "text/html;profile=mcp-app" + +// clientSupportsUI reports whether the MCP client that sent this request +// supports MCP Apps UI rendering. +// It checks the context first (set by HTTP/stateless servers from stored +// session capabilities), then falls back to the go-sdk Session (for stdio). +func clientSupportsUI(ctx context.Context, req *mcp.CallToolRequest) bool { + // Check context first (works for HTTP/stateless servers) + if supported, ok := ghcontext.HasUISupport(ctx); ok { + return supported + } + // Fall back to go-sdk session (works for stdio/stateful servers) + if req != nil && req.Session != nil { + params := req.Session.InitializeParams() + if params != nil && params.Capabilities != nil { + _, hasUI := params.Capabilities.Extensions[mcpAppsExtensionKey] + return hasUI + } + } + return false +} diff --git a/pkg/github/ui_capability_test.go b/pkg/github/ui_capability_test.go new file mode 100644 index 0000000000..72275d7c46 --- /dev/null +++ b/pkg/github/ui_capability_test.go @@ -0,0 +1,87 @@ +package github + +import ( + "context" + "testing" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createMCPRequestWithCapabilities(t *testing.T, caps *mcp.ClientCapabilities) mcp.CallToolRequest { + t.Helper() + srv := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) + st, _ := mcp.NewInMemoryTransports() + session, err := srv.Connect(context.Background(), st, &mcp.ServerSessionOptions{ + State: &mcp.ServerSessionState{ + InitializeParams: &mcp.InitializeParams{ + ClientInfo: &mcp.Implementation{Name: "test-client"}, + Capabilities: caps, + }, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + return mcp.CallToolRequest{Session: session} +} + +func Test_clientSupportsUI(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("client with UI extension", func(t *testing.T) { + caps := &mcp.ClientCapabilities{} + caps.AddExtension("io.modelcontextprotocol/ui", map[string]any{ + "mimeTypes": []string{"text/html;profile=mcp-app"}, + }) + req := createMCPRequestWithCapabilities(t, caps) + assert.True(t, clientSupportsUI(ctx, &req)) + }) + + t.Run("client without UI extension", func(t *testing.T) { + req := createMCPRequestWithCapabilities(t, &mcp.ClientCapabilities{}) + assert.False(t, clientSupportsUI(ctx, &req)) + }) + + t.Run("client with nil capabilities", func(t *testing.T) { + req := createMCPRequestWithCapabilities(t, nil) + assert.False(t, clientSupportsUI(ctx, &req)) + }) + + t.Run("nil request", func(t *testing.T) { + assert.False(t, clientSupportsUI(ctx, nil)) + }) + + t.Run("nil session", func(t *testing.T) { + req := createMCPRequest(nil) + assert.False(t, clientSupportsUI(ctx, &req)) + }) +} + +func Test_clientSupportsUI_fromContext(t *testing.T) { + t.Parallel() + + t.Run("UI supported in context", func(t *testing.T) { + ctx := ghcontext.WithUISupport(context.Background(), true) + assert.True(t, clientSupportsUI(ctx, nil)) + }) + + t.Run("UI not supported in context", func(t *testing.T) { + ctx := ghcontext.WithUISupport(context.Background(), false) + assert.False(t, clientSupportsUI(ctx, nil)) + }) + + t.Run("context takes precedence over session", func(t *testing.T) { + ctx := ghcontext.WithUISupport(context.Background(), false) + caps := &mcp.ClientCapabilities{} + caps.AddExtension("io.modelcontextprotocol/ui", map[string]any{}) + req := createMCPRequestWithCapabilities(t, caps) + assert.False(t, clientSupportsUI(ctx, &req)) + }) + + t.Run("no context or session", func(t *testing.T) { + assert.False(t, clientSupportsUI(context.Background(), nil)) + }) +} diff --git a/pkg/github/ui_dist/.gitkeep b/pkg/github/ui_dist/.gitkeep new file mode 100644 index 0000000000..22302b5aef --- /dev/null +++ b/pkg/github/ui_dist/.gitkeep @@ -0,0 +1,3 @@ +# This directory contains built UI assets generated by script/build-ui +# The .gitkeep ensures the directory exists for the Go embed directive. +# Run script/build-ui to generate the actual HTML files. diff --git a/pkg/github/ui_dist/.placeholder.html b/pkg/github/ui_dist/.placeholder.html new file mode 100644 index 0000000000..2cc67e3c2b --- /dev/null +++ b/pkg/github/ui_dist/.placeholder.html @@ -0,0 +1,4 @@ + + + +Run script/build-ui to generate UI assets diff --git a/pkg/github/ui_embed.go b/pkg/github/ui_embed.go new file mode 100644 index 0000000000..c3f1cef9d2 --- /dev/null +++ b/pkg/github/ui_embed.go @@ -0,0 +1,41 @@ +package github + +import ( + "embed" +) + +// UIAssets embeds the built MCP App UI HTML files. +// These files are generated by running `script/build-ui` which compiles +// the React/Primer components in the ui/ directory. +// +//go:embed ui_dist/*.html +var UIAssets embed.FS + +// GetUIAsset reads a UI asset from the embedded filesystem. +// The name should be just the filename (e.g., "get-me.html"). +func GetUIAsset(name string) (string, error) { + data, err := UIAssets.ReadFile("ui_dist/" + name) + if err != nil { + return "", err + } + return string(data), nil +} + +// MustGetUIAsset reads a UI asset and panics if it fails. +// Use this when the asset is required for server operation. +func MustGetUIAsset(name string) string { + html, err := GetUIAsset(name) + if err != nil { + panic("failed to load UI asset " + name + ": " + err.Error()) + } + return html +} + +// 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 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 new file mode 100644 index 0000000000..045e129360 --- /dev/null +++ b/pkg/github/ui_resources.go @@ -0,0 +1,137 @@ +package github + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// RegisterUIResources registers MCP App UI resources with the server. +// 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, readOnly bool) { + // Register the get_me UI resource + s.AddResource( + &mcp.Resource{ + URI: GetMeUIResourceURI, + Name: "get_me_ui", + Description: "MCP App UI for the get_me tool", + MIMEType: MCPAppMIMEType, + }, + func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + html := MustGetUIAsset("get-me.html") + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: GetMeUIResourceURI, + MIMEType: MCPAppMIMEType, + Text: html, + Meta: mcp.Meta{ + "ui": map[string]any{ + // Allow loading images from GitHub's avatar CDN. + "csp": map[string]any{ + "resourceDomains": []string{"https://avatars.githubusercontent.com"}, + }, + // Profile card renders inline within chat without a host border. + "prefersBorder": false, + }, + }, + }, + }, + }, nil + }, + ) + + if readOnly { + return + } + + // Register the issue_write UI resource + s.AddResource( + &mcp.Resource{ + URI: IssueWriteUIResourceURI, + Name: "issue_write_ui", + Description: "MCP App UI for creating and updating GitHub issues", + MIMEType: MCPAppMIMEType, + }, + func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + html := MustGetUIAsset("issue-write.html") + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + 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 + }, + ) + + // Register the create_pull_request UI resource + s.AddResource( + &mcp.Resource{ + URI: PullRequestWriteUIResourceURI, + Name: "pr_write_ui", + Description: "MCP App UI for creating GitHub pull requests", + MIMEType: MCPAppMIMEType, + }, + func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + html := MustGetUIAsset("pr-write.html") + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: PullRequestWriteUIResourceURI, + MIMEType: MCPAppMIMEType, + Text: html, + Meta: mcp.Meta{ + "ui": map[string]any{ + "csp": map[string]any{}, + "prefersBorder": true, + }, + }, + }, + }, + }, nil + }, + ) + + s.AddResource( + &mcp.Resource{ + URI: PullRequestEditUIResourceURI, + Name: "pr_edit_ui", + Description: "MCP App UI for editing GitHub pull requests", + MIMEType: MCPAppMIMEType, + }, + func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + html := MustGetUIAsset("pr-edit.html") + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: PullRequestEditUIResourceURI, + 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..49cce09bbd --- /dev/null +++ b/pkg/github/ui_resources_test.go @@ -0,0 +1,168 @@ +package github + +import ( + "context" + "slices" + "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, false) + + // 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, + PullRequestEditUIResourceURI, + } + 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) +} + +func TestRegisterUIResources_ReadOnlySkipsWriteResources(t *testing.T) { + t.Parallel() + + srv := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil) + RegisterUIResources(srv, true) + + st, ct := mcp.NewInMemoryTransports() + + type clientResult struct { + res *mcp.ListResourcesResult + 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) + if err != nil { + clientCh <- clientResult{err: err} + return + } + defer func() { _ = cs.Close() }() + + res, err := cs.ListResources(context.Background(), nil) + clientCh <- clientResult{res: res, 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) + require.NotNil(t, got.res) + + names := make([]string, 0, len(got.res.Resources)) + for _, res := range got.res.Resources { + names = append(names, res.Name) + } + slices.Sort(names) + + assert.Equal(t, []string{"get_me_ui"}, names) +} + +// 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/github/ui_tools.go b/pkg/github/ui_tools.go new file mode 100644 index 0000000000..640250dea3 --- /dev/null +++ b/pkg/github/ui_tools.go @@ -0,0 +1,516 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "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" +) + +// UIGet creates a tool to fetch UI data for MCP Apps. +func UIGet(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataContext, // Use context toolset so it's always available + mcp.Tool{ + Name: "ui_get", + Description: t("TOOL_UI_GET_DESCRIPTION", "Fetch UI data for MCP Apps (labels, assignees, milestones, issue types, branches, issue fields, reviewers)."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UI_GET_USER_TITLE", "Get UI data"), + ReadOnlyHint: true, + }, + // ui_get only backs MCP App views; declaring app-only visibility keeps + // it out of the agent's tool list while remaining callable by the views + // via tools/call (per the MCP Apps 2026-01-26 spec). + Meta: mcp.Meta{ + "ui": map[string]any{ + "visibility": []string{"app"}, + }, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Enum: []any{"labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers"}, + Description: "The type of data to fetch", + }, + "owner": { + Type: "string", + Description: "Repository owner (required for all methods)", + }, + "repo": { + Type: "string", + Description: "Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers)", + }, + }, + Required: []string{"method", "owner"}, + }, + }, + []scopes.Scope{scopes.Repo, scopes.ReadOrg}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case "labels": + return uiGetLabels(ctx, deps, args, owner) + case "assignees": + return uiGetAssignees(ctx, deps, args, owner) + case "milestones": + return uiGetMilestones(ctx, deps, args, owner) + case "issue_types": + return uiGetIssueTypes(ctx, deps, owner) + case "branches": + return uiGetBranches(ctx, deps, args, owner) + case "issue_fields": + return uiGetIssueFields(ctx, deps, args, owner) + case "reviewers": + return uiGetReviewers(ctx, deps, args, owner) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }) + st.FeatureFlagEnable = MCPAppsFeatureFlag + return st +} + +func uiGetLabels(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + var query struct { + Repository struct { + Labels struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } + TotalCount githubv4.Int + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + } `graphql:"labels(first: 100, after: $cursor)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "cursor": (*githubv4.String)(nil), + } + + labels := make([]map[string]any, 0) + var totalCount int + for { + if err := client.Query(ctx, &query, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil, nil + } + for _, labelNode := range query.Repository.Labels.Nodes { + labels = append(labels, map[string]any{ + "id": fmt.Sprintf("%v", labelNode.ID), + "name": string(labelNode.Name), + "color": string(labelNode.Color), + "description": string(labelNode.Description), + }) + } + totalCount = int(query.Repository.Labels.TotalCount) + if !query.Repository.Labels.PageInfo.HasNextPage { + break + } + vars["cursor"] = githubv4.NewString(query.Repository.Labels.PageInfo.EndCursor) + } + + response := map[string]any{ + "labels": labels, + "totalCount": totalCount, + } + + out, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal labels: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func uiGetAssignees(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + opts := &github.ListOptions{PerPage: 100} + var allAssignees []*github.User + + for { + assignees, resp, err := client.Issues.ListAssignees(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list assignees", resp, err), nil, nil + } + allAssignees = append(allAssignees, assignees...) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + result := make([]map[string]string, len(allAssignees)) + for i, u := range allAssignees { + result[i] = map[string]string{ + "login": u.GetLogin(), + "avatar_url": u.GetAvatarURL(), + } + } + + out, err := json.Marshal(map[string]any{ + "assignees": result, + "totalCount": len(result), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal assignees", err), nil, nil + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func uiGetMilestones(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + opts := &github.MilestoneListOptions{ + State: "open", + ListOptions: github.ListOptions{PerPage: 100}, + } + + var allMilestones []*github.Milestone + for { + milestones, resp, err := client.Issues.ListMilestones(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list milestones", resp, err), nil, nil + } + allMilestones = append(allMilestones, milestones...) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + result := make([]map[string]any, len(allMilestones)) + for i, m := range allMilestones { + dueOn := "" + if m.DueOn != nil { + dueOn = m.GetDueOn().Format("2006-01-02") + } + result[i] = map[string]any{ + "number": m.GetNumber(), + "title": m.GetTitle(), + "description": m.GetDescription(), + "state": m.GetState(), + "open_issues": m.GetOpenIssues(), + "due_on": dueOn, + } + } + + out, err := json.Marshal(map[string]any{ + "milestones": result, + "totalCount": len(result), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal milestones", err), nil, nil + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func uiGetIssueTypes(ctx context.Context, deps ToolDependencies, owner string) (*mcp.CallToolResult, any, error) { + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list issue types", 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 list issue types", resp, body), nil, nil + } + + r, err := json.Marshal(issueTypes) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal issue types", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func uiGetBranches(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + opts := &github.BranchListOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + + var allBranches []*github.Branch + for { + branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list branches", resp, err), nil, nil + } + allBranches = append(allBranches, branches...) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + minimalBranches := make([]MinimalBranch, 0, len(allBranches)) + for _, branch := range allBranches { + minimalBranches = append(minimalBranches, convertToMinimalBranch(branch)) + } + + r, err := json.Marshal(map[string]any{ + "branches": minimalBranches, + "totalCount": len(minimalBranches), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func uiGetIssueFields(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + if !deps.IsFeatureEnabled(ctx, FeatureFlagIssueFields) { + return marshalUIGetIssueFields(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 + } + + return marshalUIGetIssueFields(fields) +} + +func marshalUIGetIssueFields(fields []IssueField) (*mcp.CallToolResult, any, error) { + resultFields := make([]map[string]any, 0, len(fields)) + for _, field := range fields { + if !uiSupportedIssueFieldDataType(field.DataType) { + continue + } + + fieldResult := map[string]any{ + "id": field.ID, + "name": field.Name, + "data_type": field.DataType, + "description": field.Description, + } + + if field.DataType == "single_select" { + fieldOptions := append([]IssueSingleSelectFieldOption(nil), field.Options...) + sort.SliceStable(fieldOptions, func(i, j int) bool { + left, leftOK := issueFieldOptionPriority(fieldOptions[i]) + right, rightOK := issueFieldOptionPriority(fieldOptions[j]) + if leftOK != rightOK { + return leftOK + } + return left < right + }) + + options := make([]map[string]string, 0, len(fieldOptions)) + for _, option := range fieldOptions { + options = append(options, map[string]string{ + "name": option.Name, + "description": option.Description, + "color": option.Color, + }) + } + fieldResult["options"] = options + } + + resultFields = append(resultFields, fieldResult) + } + + r, err := json.Marshal(map[string]any{ + "fields": resultFields, + "totalCount": len(resultFields), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func uiSupportedIssueFieldDataType(dataType string) bool { + switch dataType { + case "text", "number", "date", "single_select": + return true + default: + return false + } +} + +func issueFieldOptionPriority(option IssueSingleSelectFieldOption) (int, bool) { + if option.Priority == nil { + return 0, false + } + return *option.Priority, true +} + +func uiGetReviewers(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + collaboratorOpts := &github.ListCollaboratorsOptions{ + Affiliation: "all", + ListOptions: github.ListOptions{PerPage: 100}, + } + var allCollaborators []*github.User + for { + collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, collaboratorOpts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list reviewers", resp, err), nil, nil + } + allCollaborators = append(allCollaborators, collaborators...) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if resp.NextPage == 0 { + break + } + collaboratorOpts.Page = resp.NextPage + } + + teamOpts := &github.ListOptions{PerPage: 100} + var allTeams []*github.Team + for { + teams, resp, err := client.Repositories.ListTeams(ctx, owner, repo, teamOpts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list reviewer teams", resp, err), nil, nil + } + allTeams = append(allTeams, teams...) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if resp.NextPage == 0 { + break + } + teamOpts.Page = resp.NextPage + } + + users := make([]map[string]string, 0, len(allCollaborators)) + for _, user := range allCollaborators { + login := user.GetLogin() + if user.GetType() == "Bot" || strings.HasSuffix(login, "[bot]") { + continue + } + users = append(users, map[string]string{ + "login": login, + "avatar_url": user.GetAvatarURL(), + }) + } + + teams := make([]map[string]string, len(allTeams)) + for i, team := range allTeams { + teams[i] = map[string]string{ + "slug": team.GetSlug(), + "name": team.GetName(), + "org": owner, + } + } + + r, err := json.Marshal(map[string]any{ + "users": users, + "teams": teams, + "totalCount": len(users) + len(teams), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal reviewers", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil +} diff --git a/pkg/github/ui_tools_test.go b/pkg/github/ui_tools_test.go new file mode 100644 index 0000000000..2fded6b20e --- /dev/null +++ b/pkg/github/ui_tools_test.go @@ -0,0 +1,414 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "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/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_UIGet(t *testing.T) { + // Verify tool definition + serverTool := UIGet(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "ui_get", 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.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner"}) + assert.True(t, tool.Annotations.ReadOnlyHint, "ui_get should be read-only") + assert.Equal(t, MCPAppsFeatureFlag, serverTool.FeatureFlagEnable, "ui_get should be gated on the MCP Apps feature flag") + + // ui_get must be app-only so the host hides it from the agent's tool list + // while keeping it callable by the views (MCP Apps 2026-01-26 spec). + ui, ok := tool.Meta["ui"].(map[string]any) + require.True(t, ok, "ui_get should declare _meta.ui") + assert.Equal(t, []string{"app"}, ui["visibility"], "ui_get should be app-only") + + // Setup mock data + mockAssignees := []*github.User{ + {Login: github.Ptr("user1"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1")}, + {Login: github.Ptr("user2"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/2")}, + } + + mockBranches := []*github.Branch{ + {Name: github.Ptr("main"), Protected: github.Ptr(true)}, + {Name: github.Ptr("feature"), Protected: github.Ptr(false)}, + } + + dueDate := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC) + mockMilestones := []*github.Milestone{ + {Number: github.Ptr(1), Title: github.Ptr("with due date"), DueOn: &github.Timestamp{Time: dueDate}}, + {Number: github.Ptr(2), Title: github.Ptr("no due date")}, + } + + mockIssueTypes := []*github.IssueType{ + {Name: github.Ptr("Bug")}, + {Name: github.Ptr("Feature")}, + } + + mockReviewers := []*github.User{ + {Login: github.Ptr("octocat"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/583231")}, + {Login: github.Ptr("dependabot[bot]"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/in/29110")}, + {Login: github.Ptr("github-actions"), Type: github.Ptr("Bot")}, + } + + mockReviewerTeams := []*github.Team{ + {Slug: github.Ptr("docs"), Name: github.Ptr("Docs")}, + } + + tests := []struct { + name string + mockedClient *http.Client + mockedGQLClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + validateResult func(t *testing.T, responseText string) + }{ + { + name: "successful assignees fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/assignees": mockResponse(t, http.StatusOK, mockAssignees), + }), + requestArgs: map[string]any{ + "method": "assignees", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + assert.Contains(t, response, "assignees") + assert.Contains(t, response, "totalCount") + }, + }, + { + name: "successful branches fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/branches": mockResponse(t, http.StatusOK, mockBranches), + }), + requestArgs: map[string]any{ + "method": "branches", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + assert.Contains(t, response, "branches") + assert.Contains(t, response, "totalCount") + }, + }, + { + name: "successful milestones fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/milestones": mockResponse(t, http.StatusOK, mockMilestones), + }), + requestArgs: map[string]any{ + "method": "milestones", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + milestones, ok := response["milestones"].([]any) + require.True(t, ok, "milestones should be a list") + require.Len(t, milestones, 2) + first := milestones[0].(map[string]any) + assert.Equal(t, "2026-01-31", first["due_on"], "milestone with a due date should be formatted") + second := milestones[1].(map[string]any) + assert.Equal(t, "", second["due_on"], "milestone without a due date should be empty, not zero time") + }, + }, + { + name: "successful issue_types fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/owner/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes), + }), + requestArgs: map[string]any{ + "method": "issue_types", + "owner": "owner", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var issueTypes []map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &issueTypes)) + require.Len(t, issueTypes, 2) + assert.Equal(t, "Bug", issueTypes[0]["name"]) + }, + }, + { + name: "issue_types API error returns response context", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/owner/issue-types": mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"}), + }), + requestArgs: map[string]any{ + "method": "issue_types", + "owner": "owner", + }, + expectError: true, + expectedErrMsg: "failed to list issue types", + }, + { + name: "successful labels fetch", + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Labels struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } + TotalCount githubv4.Int + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + } `graphql:"labels(first: 100, after: $cursor)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "cursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "labels": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("label-1"), + "name": githubv4.String("bug"), + "color": githubv4.String("d73a4a"), + "description": githubv4.String("Something isn't working"), + }, + }, + "totalCount": githubv4.Int(1), + "pageInfo": map[string]any{ + "hasNextPage": githubv4.Boolean(false), + "endCursor": githubv4.String(""), + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "method": "labels", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + labels, ok := response["labels"].([]any) + require.True(t, ok, "labels should be a list") + require.Len(t, labels, 1) + assert.Equal(t, "bug", labels[0].(map[string]any)["name"]) + assert.Equal(t, float64(1), response["totalCount"]) + }, + }, + { + name: "issue_fields feature disabled returns empty list", + requestArgs: map[string]any{ + "method": "issue_fields", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + fields, ok := response["fields"].([]any) + require.True(t, ok, "fields should be a list") + assert.Empty(t, fields) + assert.Equal(t, float64(0), response["totalCount"]) + }, + }, + { + name: "successful reviewers fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/collaborators": mockResponse(t, http.StatusOK, mockReviewers), + "GET /repos/owner/repo/teams": mockResponse(t, http.StatusOK, mockReviewerTeams), + }), + requestArgs: map[string]any{ + "method": "reviewers", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + users, ok := response["users"].([]any) + require.True(t, ok, "users should be a list") + require.Len(t, users, 1) + assert.Equal(t, "octocat", users[0].(map[string]any)["login"]) + teams, ok := response["teams"].([]any) + require.True(t, ok, "teams should be a list") + require.Len(t, teams, 1) + assert.Equal(t, "docs", teams[0].(map[string]any)["slug"]) + assert.Equal(t, "owner", teams[0].(map[string]any)["org"]) + assert.Equal(t, float64(2), response["totalCount"]) + }, + }, + { + name: "missing method parameter", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: method", + }, + { + name: "missing owner parameter", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "assignees", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing repo parameter for assignees", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "assignees", + "owner": "owner", + }, + expectError: true, + expectedErrMsg: "missing required parameter: repo", + }, + { + name: "unknown method", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "unknown", + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "unknown method: unknown", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup deps with REST and/or GraphQL mocks + deps := BaseDeps{} + if tc.mockedClient != nil { + client, err := github.NewClient(github.WithHTTPClient(tc.mockedClient)) + require.NoError(t, err) + deps.Client = client + } + if tc.mockedGQLClient != nil { + deps.GQLClient = githubv4.NewClient(tc.mockedGQLClient) + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + // Verify results + 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) + + if tc.validateResult != nil { + tc.validateResult(t, textContent.Text) + } + }) + } +} + +func Test_marshalUIGetIssueFields_TrimsForUI(t *testing.T) { + priorityLow := 1 + priorityHigh := 2 + result, _, err := marshalUIGetIssueFields([]IssueField{ + { + ID: "field-1", + DatabaseID: 123, + Name: "Priority", + Description: "How urgent this is", + DataType: "single_select", + Visibility: "public", + Options: []IssueSingleSelectFieldOption{ + {ID: "option-2", Name: "High", Description: "High priority", Color: "red", Priority: &priorityHigh}, + {ID: "option-1", Name: "Low", Description: "Low priority", Color: "blue", Priority: &priorityLow}, + {ID: "option-3", Name: "No priority", Description: "No priority set", Color: "gray"}, + }, + }, + { + ID: "field-2", + Name: "Unsupported", + DataType: "iteration", + }, + { + ID: "field-3", + Name: "Notes", + DataType: "text", + }, + }) + require.NoError(t, err) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + fields := response["fields"].([]any) + require.Len(t, fields, 2) + assert.Equal(t, float64(2), response["totalCount"]) + + singleSelectField := fields[0].(map[string]any) + assert.NotContains(t, singleSelectField, "full_database_id") + assert.NotContains(t, singleSelectField, "visibility") + options := singleSelectField["options"].([]any) + require.Len(t, options, 3) + assert.Equal(t, "Low", options[0].(map[string]any)["name"]) + assert.Equal(t, "High", options[1].(map[string]any)["name"]) + assert.Equal(t, "No priority", options[2].(map[string]any)["name"]) + assert.NotContains(t, options[0].(map[string]any), "id") + assert.NotContains(t, options[0].(map[string]any), "priority") + + textField := fields[1].(map[string]any) + assert.NotContains(t, textField, "options") +} diff --git a/pkg/http/handler.go b/pkg/http/handler.go new file mode 100644 index 0000000000..eca628a47b --- /dev/null +++ b/pkg/http/handler.go @@ -0,0 +1,421 @@ +package http + +import ( + "context" + "errors" + "log/slog" + "net/http" + + 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/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/go-chi/chi/v5" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type InventoryFactoryFunc func(r *http.Request) (*inventory.Inventory, error) + +// GitHubMCPServerFactoryFunc is a function type for creating a new MCP Server instance. +// middleware are applied AFTER the default GitHub MCP Server middlewares (like error context injection) +type GitHubMCPServerFactoryFunc func(r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error) + +type Handler struct { + ctx context.Context + config *ServerConfig + deps github.ToolDependencies + logger *slog.Logger + apiHosts utils.APIHostResolver + t translations.TranslationHelperFunc + githubMcpServerFactory GitHubMCPServerFactoryFunc + inventoryFactoryFunc InventoryFactoryFunc + oauthCfg *oauth.Config + scopeFetcher scopes.FetcherInterface + schemaCache *mcp.SchemaCache +} + +type HandlerOptions struct { + GitHubMcpServerFactory GitHubMCPServerFactoryFunc + InventoryFactory InventoryFactoryFunc + OAuthConfig *oauth.Config + ScopeFetcher scopes.FetcherInterface + FeatureChecker inventory.FeatureFlagChecker +} + +type HandlerOption func(*HandlerOptions) + +func WithScopeFetcher(f scopes.FetcherInterface) HandlerOption { + return func(o *HandlerOptions) { + o.ScopeFetcher = f + } +} + +func WithGitHubMCPServerFactory(f GitHubMCPServerFactoryFunc) HandlerOption { + return func(o *HandlerOptions) { + o.GitHubMcpServerFactory = f + } +} + +func WithInventoryFactory(f InventoryFactoryFunc) HandlerOption { + return func(o *HandlerOptions) { + o.InventoryFactory = f + } +} + +func WithOAuthConfig(cfg *oauth.Config) HandlerOption { + return func(o *HandlerOptions) { + o.OAuthConfig = cfg + } +} + +func WithFeatureChecker(checker inventory.FeatureFlagChecker) HandlerOption { + return func(o *HandlerOptions) { + o.FeatureChecker = checker + } +} + +func NewHTTPMcpHandler( + ctx context.Context, + cfg *ServerConfig, + deps github.ToolDependencies, + t translations.TranslationHelperFunc, + logger *slog.Logger, + apiHost utils.APIHostResolver, + options ...HandlerOption) *Handler { + opts := &HandlerOptions{} + for _, o := range options { + o(opts) + } + + githubMcpServerFactory := opts.GitHubMcpServerFactory + if githubMcpServerFactory == nil { + githubMcpServerFactory = DefaultGitHubMCPServerFactory + } + + scopeFetcher := opts.ScopeFetcher + if scopeFetcher == nil { + scopeFetcher = scopes.NewFetcher(apiHost, scopes.FetcherOptions{}) + } + + inventoryFactory := opts.InventoryFactory + if inventoryFactory == nil { + inventoryFactory = DefaultInventoryFactory(cfg, t, opts.FeatureChecker, scopeFetcher) + } + + // Create a shared schema cache to avoid repeated JSON schema reflection + // when a new MCP Server is created per request in stateless mode. + schemaCache := mcp.NewSchemaCache() + + return &Handler{ + ctx: ctx, + config: cfg, + deps: deps, + logger: logger, + apiHosts: apiHost, + t: t, + githubMcpServerFactory: githubMcpServerFactory, + inventoryFactoryFunc: inventoryFactory, + oauthCfg: opts.OAuthConfig, + scopeFetcher: scopeFetcher, + schemaCache: schemaCache, + } +} + +func (h *Handler) RegisterMiddleware(r chi.Router) { + r.Use( + middleware.ExtractUserToken(h.oauthCfg), + middleware.WithRequestConfig, + middleware.WithMCPParse(), + middleware.WithPATScopes(h.logger, h.scopeFetcher), + ) + + if h.config.ScopeChallenge { + r.Use(middleware.WithScopeChallenge(h.oauthCfg, h.scopeFetcher)) + } +} + +// RegisterRoutes registers the routes for the MCP server +// URL-based values take precedence over header-based values +func (h *Handler) RegisterRoutes(r chi.Router) { + // Base routes + r.Mount("/", h) + r.With(withReadonly).Mount("/readonly", h) + r.With(withInsiders).Mount("/insiders", h) + r.With(withReadonly, withInsiders).Mount("/readonly/insiders", h) + + // Toolset routes + r.With(withToolset).Mount("/x/{toolset}", h) + r.With(withToolset, withReadonly).Mount("/x/{toolset}/readonly", h) + r.With(withToolset, withInsiders).Mount("/x/{toolset}/insiders", h) + r.With(withToolset, withReadonly, withInsiders).Mount("/x/{toolset}/readonly/insiders", h) +} + +// withReadonly is middleware that sets readonly mode in the request context +func withReadonly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := ghcontext.WithReadonly(r.Context(), true) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// withToolset is middleware that extracts the toolset from the URL and sets it in the request context +func withToolset(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + toolset := chi.URLParam(r, "toolset") + ctx := ghcontext.WithToolsets(r.Context(), []string{toolset}) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// withInsiders is middleware that sets insiders mode in the request context +func withInsiders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := ghcontext.WithInsidersMode(r.Context(), true) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + inv, err := h.inventoryFactoryFunc(r) + if err != nil { + if errors.Is(err, inventory.ErrUnknownTools) { + w.WriteHeader(http.StatusBadRequest) + if _, writeErr := w.Write([]byte(err.Error())); writeErr != nil { + h.logger.Error("failed to write response", "error", writeErr) + } + return + } + + w.WriteHeader(http.StatusInternalServerError) + return + } + + invToUse := inv + if methodInfo, ok := ghcontext.MCPMethod(r.Context()); ok && methodInfo != nil { + invToUse = inv.ForMCPRequest(methodInfo.Method, methodInfo.ItemName) + } + + ghServer, err := h.githubMcpServerFactory(r, h.deps, invToUse, &github.MCPServerConfig{ + Version: h.config.Version, + Translator: h.t, + ContentWindowSize: h.config.ContentWindowSize, + Logger: h.logger, + RepoAccessTTL: h.config.RepoAccessCacheTTL, + // Explicitly set empty capabilities. inv.ForMCPRequest currently returns nothing for Initialize. + ServerOptions: []github.MCPServerOption{ + func(so *mcp.ServerOptions) { + so.Capabilities = &mcp.ServerCapabilities{ + Tools: &mcp.ToolCapabilities{}, + Resources: &mcp.ResourceCapabilities{}, + Prompts: &mcp.PromptCapabilities{}, + } + so.SchemaCache = h.schemaCache + }, + }, + }) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + 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{ + Stateless: true, + }) + + mcpHandler.ServeHTTP(w, r) +} + +func DefaultGitHubMCPServerFactory(r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error) { + return github.NewMCPServer(r.Context(), cfg, deps, inventory) +} + +// 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 := 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) + + b.WithServerInstructions() + + return b.Build() + } +} + +// 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. +// 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() + + if ghcontext.IsReadonly(ctx) { + builder = builder.WithReadOnly(true) + } + + toolsets := ghcontext.GetToolsets(ctx) + tools := ghcontext.GetTools(ctx) + + if len(toolsets) > 0 { + builder = builder.WithToolsets(github.ResolvedEnabledToolsets(toolsets, tools)) + } + + if len(tools) > 0 { + if len(toolsets) == 0 { + builder = builder.WithToolsets([]string{}) + } + builder = builder.WithTools(github.CleanTools(tools)) + } + + if excluded := ghcontext.GetExcludeTools(ctx); len(excluded) > 0 { + builder = builder.WithExcludeTools(excluded) + } + + return builder +} + +func PATScopeFilter(b *inventory.Builder, r *http.Request, fetcher scopes.FetcherInterface) *inventory.Builder { + ctx := r.Context() + + tokenInfo, ok := ghcontext.GetTokenInfo(ctx) + if !ok || tokenInfo == nil { + return b + } + + // Scopes should have already been fetched by the WithPATScopes middleware. + // Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header. + // Fine-grained PATs and other token types don't support this, so we skip filtering. + if tokenInfo.TokenType == utils.TokenTypePersonalAccessToken { + // Check if scopes are already in context (should be set by WithPATScopes). If not, fetch them. + existingScopes, ok := ghcontext.GetTokenScopes(ctx) + if ok { + return b.WithFilter(github.CreateToolScopeFilter(existingScopes)) + } + + scopesList, err := fetcher.FetchTokenScopes(ctx, tokenInfo.Token) + if err != nil { + return b + } + + return b.WithFilter(github.CreateToolScopeFilter(scopesList)) + } + + return b +} diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go new file mode 100644 index 0000000000..4f697ee0cb --- /dev/null +++ b/pkg/http/handler_test.go @@ -0,0 +1,947 @@ +package http + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "slices" + "sort" + "strings" + "testing" + + 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/headers" + "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-chi/chi/v5" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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, + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: readOnly}, + }, + Toolset: inventory.ToolsetMetadata{ + ID: inventory.ToolsetID(toolsetID), + Description: "Test: " + toolsetID, + Default: isDefault, + }, + } +} + +type allScopesFetcher struct{} + +func (f allScopesFetcher) FetchTokenScopes(_ context.Context, _ string) ([]string, error) { + return []string{ + string(scopes.Repo), + string(scopes.WriteOrg), + string(scopes.User), + string(scopes.Gist), + string(scopes.Notifications), + }, nil +} + +var _ scopes.FetcherInterface = allScopesFetcher{} + +func mockToolWithFeatureFlag(name, toolsetID string, readOnly bool, enableFlag, disableFlag string) inventory.ServerTool { + tool := mockTool(name, toolsetID, readOnly) + tool.FeatureFlagEnable = enableFlag + if disableFlag != "" { + tool.FeatureFlagDisable = []string{disableFlag} + } + return tool +} + +func TestInventoryFiltersForRequest(t *testing.T) { + tools := []inventory.ServerTool{ + mockTool("get_file_contents", "repos", true), + mockTool("create_repository", "repos", false), + mockTool("list_issues", "issues", true), + mockTool("issue_write", "issues", false), + } + + tests := []struct { + name string + contextSetup func(context.Context) context.Context + expectedTools []string + }{ + { + name: "no filters applies defaults", + contextSetup: func(ctx context.Context) context.Context { return ctx }, + expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "issue_write"}, + }, + { + name: "readonly from context filters write tools", + contextSetup: func(ctx context.Context) context.Context { + return ghcontext.WithReadonly(ctx, true) + }, + expectedTools: []string{"get_file_contents", "list_issues"}, + }, + { + name: "toolset from context filters to toolset", + contextSetup: func(ctx context.Context) context.Context { + return ghcontext.WithToolsets(ctx, []string{"repos"}) + }, + expectedTools: []string{"get_file_contents", "create_repository"}, + }, + { + name: "tools alone clears default toolsets", + contextSetup: func(ctx context.Context) context.Context { + return ghcontext.WithTools(ctx, []string{"list_issues"}) + }, + expectedTools: []string{"list_issues"}, + }, + { + name: "tools are additive with toolsets", + contextSetup: func(ctx context.Context) context.Context { + ctx = ghcontext.WithToolsets(ctx, []string{"repos"}) + ctx = ghcontext.WithTools(ctx, []string{"list_issues"}) + return ctx + }, + expectedTools: []string{"get_file_contents", "create_repository", "list_issues"}, + }, + { + name: "excluded tools removes specific tools", + contextSetup: func(ctx context.Context) context.Context { + return ghcontext.WithExcludeTools(ctx, []string{"create_repository", "issue_write"}) + }, + expectedTools: []string{"get_file_contents", "list_issues"}, + }, + { + name: "excluded tools overrides explicit tools", + contextSetup: func(ctx context.Context) context.Context { + ctx = ghcontext.WithTools(ctx, []string{"list_issues", "create_repository"}) + ctx = ghcontext.WithExcludeTools(ctx, []string{"create_repository"}) + return ctx + }, + expectedTools: []string{"list_issues"}, + }, + { + name: "excluded tools combines with readonly", + contextSetup: func(ctx context.Context) context.Context { + ctx = ghcontext.WithReadonly(ctx, true) + ctx = ghcontext.WithExcludeTools(ctx, []string{"list_issues"}) + return ctx + }, + expectedTools: []string{"get_file_contents"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req = req.WithContext(tt.contextSetup(req.Context())) + + builder := inventory.NewBuilder(). + SetTools(tools). + WithToolsets([]string{"all"}) + + builder = InventoryFiltersForRequest(req, builder) + inv, err := builder.Build() + require.NoError(t, err) + + available := inv.AvailableTools(context.Background()) + toolNames := make([]string, len(available)) + for i, tool := range available { + toolNames[i] = tool.Tool.Name + } + + assert.ElementsMatch(t, tt.expectedTools, toolNames) + }) + } +} + +// testTools returns a set of mock tools across different toolsets with mixed read-only/write capabilities +func testTools() []inventory.ServerTool { + return []inventory.ServerTool{ + mockTool("get_file_contents", "repos", true), + mockTool("create_repository", "repos", false), + mockTool("list_issues", "issues", true), + mockTool("create_issue", "issues", false), + mockTool("list_pull_requests", "pull_requests", true), + mockTool("create_pull_request", "pull_requests", false), + // Feature-flagged tools for testing X-MCP-Features header + mockToolWithFeatureFlag("needs_holdback", "repos", true, "mcp_holdback_consolidated_projects", ""), + mockToolWithFeatureFlag("hidden_by_holdback", "repos", true, "", "mcp_holdback_consolidated_projects"), + } +} + +// extractToolNames extracts tool names from an inventory +func extractToolNames(ctx context.Context, inv *inventory.Inventory) []string { + available := inv.AvailableTools(ctx) + names := make([]string, len(available)) + for i, tool := range available { + names[i] = tool.Tool.Name + } + sort.Strings(names) + return names +} + +func TestHTTPHandlerRoutes(t *testing.T) { + tools := testTools() + + tests := []struct { + name string + path string + headers map[string]string + expectedTools []string + }{ + { + name: "root path returns all tools", + path: "/", + expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"}, + }, + { + name: "readonly path filters write tools", + path: "/readonly", + expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"}, + }, + { + name: "toolset path filters to toolset", + path: "/x/repos", + expectedTools: []string{"get_file_contents", "create_repository", "hidden_by_holdback"}, + }, + { + name: "toolset path with issues", + path: "/x/issues", + expectedTools: []string{"list_issues", "create_issue"}, + }, + { + name: "toolset readonly path filters to readonly tools in toolset", + path: "/x/repos/readonly", + expectedTools: []string{"get_file_contents", "hidden_by_holdback"}, + }, + { + name: "toolset readonly path with issues", + path: "/x/issues/readonly", + expectedTools: []string{"list_issues"}, + }, + { + name: "X-MCP-Tools header filters to specific tools", + path: "/", + headers: map[string]string{ + headers.MCPToolsHeader: "list_issues", + }, + expectedTools: []string{"list_issues"}, + }, + { + name: "X-MCP-Tools header with multiple tools", + path: "/", + headers: map[string]string{ + headers.MCPToolsHeader: "list_issues,get_file_contents", + }, + expectedTools: []string{"list_issues", "get_file_contents"}, + }, + { + name: "X-MCP-Tools header does not expose extra tools", + path: "/", + headers: map[string]string{ + headers.MCPToolsHeader: "list_issues", + }, + expectedTools: []string{"list_issues"}, + }, + { + name: "X-MCP-Readonly header filters write tools", + path: "/", + headers: map[string]string{ + headers.MCPReadOnlyHeader: "true", + }, + expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"}, + }, + { + name: "X-MCP-Toolsets header filters to toolset", + path: "/", + headers: map[string]string{ + headers.MCPToolsetsHeader: "repos", + }, + expectedTools: []string{"get_file_contents", "create_repository", "hidden_by_holdback"}, + }, + { + name: "URL toolset takes precedence over header toolset", + path: "/x/issues", + headers: map[string]string{ + headers.MCPToolsetsHeader: "repos", + }, + expectedTools: []string{"list_issues", "create_issue"}, + }, + { + name: "URL readonly takes precedence over header", + path: "/readonly", + headers: map[string]string{ + headers.MCPReadOnlyHeader: "false", + }, + expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"}, + }, + { + name: "X-MCP-Features header enables flagged tool", + path: "/", + headers: map[string]string{ + headers.MCPFeaturesHeader: "mcp_holdback_consolidated_projects", + }, + expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "needs_holdback"}, + }, + { + name: "X-MCP-Features header with unknown flag is ignored", + path: "/", + headers: map[string]string{ + headers.MCPFeaturesHeader: "unknown_flag", + }, + expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"}, + }, + { + name: "X-MCP-Exclude-Tools header removes specific tools", + path: "/", + headers: map[string]string{ + headers.MCPExcludeToolsHeader: "create_issue,create_pull_request", + }, + expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "list_pull_requests", "hidden_by_holdback"}, + }, + { + name: "X-MCP-Exclude-Tools with toolset header", + path: "/", + headers: map[string]string{ + headers.MCPToolsetsHeader: "issues", + headers.MCPExcludeToolsHeader: "create_issue", + }, + expectedTools: []string{"list_issues"}, + }, + { + name: "X-MCP-Exclude-Tools overrides X-MCP-Tools", + path: "/", + headers: map[string]string{ + headers.MCPToolsHeader: "list_issues,create_issue", + headers.MCPExcludeToolsHeader: "create_issue", + }, + expectedTools: []string{"list_issues"}, + }, + { + name: "X-MCP-Exclude-Tools with readonly path", + path: "/readonly", + headers: map[string]string{ + headers.MCPExcludeToolsHeader: "list_issues", + }, + expectedTools: []string{"get_file_contents", "list_pull_requests", "hidden_by_holdback"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var capturedInventory *inventory.Inventory + var capturedCtx context.Context + + // Create feature checker that reads from context without whitelist validation + // (the whitelist is tested separately; here we test the filtering logic) + 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) + + // Create inventory factory that captures the built inventory + inventoryFactory := func(r *http.Request) (*inventory.Inventory, error) { + capturedCtx = r.Context() + builder := inventory.NewBuilder(). + SetTools(tools). + WithToolsets([]string{"all"}). + WithFeatureChecker(featureChecker) + builder = InventoryFiltersForRequest(r, builder) + inv, err := builder.Build() + if err != nil { + return nil, err + } + capturedInventory = inv + return inv, nil + } + + // Create mock MCP server factory that just returns a minimal server + 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 + } + + allScopesFetcher := allScopesFetcher{} + + // Create handler with our factories + handler := NewHTTPMcpHandler( + context.Background(), + &ServerConfig{Version: "test"}, + nil, // deps not needed for this test + translations.NullTranslationHelper, + slog.Default(), + apiHost, + WithInventoryFactory(inventoryFactory), + WithGitHubMCPServerFactory(mcpServerFactory), + WithScopeFetcher(allScopesFetcher), + ) + + // Create router and register routes + r := chi.NewRouter() + handler.RegisterMiddleware(r) + handler.RegisterRoutes(r) + + // Create request + req := httptest.NewRequest(http.MethodPost, tt.path, nil) + + // Ensure we're setting Authorization header for token context + req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_testtoken") + + for k, v := range tt.headers { + req.Header.Set(k, v) + } + + // Execute request + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + // Verify the inventory was captured and has the expected tools + 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 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/headers/headers.go b/pkg/http/headers/headers.go new file mode 100644 index 0000000000..e032a0ce93 --- /dev/null +++ b/pkg/http/headers/headers.go @@ -0,0 +1,56 @@ +package headers + +const ( + // AuthorizationHeader is a standard HTTP Header. + AuthorizationHeader = "Authorization" + // ContentTypeHeader is a standard HTTP Header. + ContentTypeHeader = "Content-Type" + // AcceptHeader is a standard HTTP Header. + AcceptHeader = "Accept" + // UserAgentHeader is a standard HTTP Header. + UserAgentHeader = "User-Agent" + + // ContentTypeJSON is the standard MIME type for JSON. + ContentTypeJSON = "application/json" + // ContentTypeEventStream is the standard MIME type for Event Streams. + ContentTypeEventStream = "text/event-stream" + + // ForwardedForHeader is a standard HTTP Header used to forward the originating IP address of a client. + ForwardedForHeader = "X-Forwarded-For" + + // RealIPHeader is a standard HTTP Header used to indicate the real IP address of the client. + RealIPHeader = "X-Real-IP" + + // ForwardedHostHeader is a standard HTTP Header for preserving the original Host header when proxying. + ForwardedHostHeader = "X-Forwarded-Host" + // ForwardedProtoHeader is a standard HTTP Header for preserving the original protocol when proxying. + ForwardedProtoHeader = "X-Forwarded-Proto" + + // RequestHmacHeader is used to authenticate requests to the Raw API. + RequestHmacHeader = "Request-Hmac" + + // MCP-specific headers. + + // MCPReadOnlyHeader indicates whether the MCP is in read-only mode. + MCPReadOnlyHeader = "X-MCP-Readonly" + // MCPToolsetsHeader is a comma-separated list of MCP toolsets that the request is for. + MCPToolsetsHeader = "X-MCP-Toolsets" + // MCPToolsHeader is a comma-separated list of MCP tools that the request is for. + MCPToolsHeader = "X-MCP-Tools" + // MCPLockdownHeader indicates whether lockdown mode is enabled. + MCPLockdownHeader = "X-MCP-Lockdown" + // MCPInsidersHeader indicates whether insiders mode is enabled for early access features. + MCPInsidersHeader = "X-MCP-Insiders" + // MCPExcludeToolsHeader is a comma-separated list of MCP tools that should be + // disabled regardless of other settings or header values. + MCPExcludeToolsHeader = "X-MCP-Exclude-Tools" + // MCPFeaturesHeader is a comma-separated list of feature flags to enable. + MCPFeaturesHeader = "X-MCP-Features" + + // GitHub-specific headers. + + // GraphQLFeaturesHeader is a comma-separated list of GraphQL feature flags to enable for GraphQL requests. + GraphQLFeaturesHeader = "GraphQL-Features" + // GitHubAPIVersionHeader is the header used to specify the GitHub API version. + GitHubAPIVersionHeader = "X-GitHub-Api-Version" +) diff --git a/pkg/http/headers/parse.go b/pkg/http/headers/parse.go new file mode 100644 index 0000000000..2b5eddacdb --- /dev/null +++ b/pkg/http/headers/parse.go @@ -0,0 +1,21 @@ +package headers + +import "strings" + +// ParseCommaSeparated splits a header value by comma, trims whitespace, +// and filters out empty values +func ParseCommaSeparated(value string) []string { + if value == "" { + return []string{} + } + + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} diff --git a/pkg/http/headers/parse_test.go b/pkg/http/headers/parse_test.go new file mode 100644 index 0000000000..d8b55a696b --- /dev/null +++ b/pkg/http/headers/parse_test.go @@ -0,0 +1,58 @@ +package headers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseCommaSeparated(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "empty string", + input: "", + expected: []string{}, + }, + { + name: "single value", + input: "foo", + expected: []string{"foo"}, + }, + { + name: "multiple values", + input: "foo,bar,baz", + expected: []string{"foo", "bar", "baz"}, + }, + { + name: "whitespace trimmed", + input: " foo , bar , baz ", + expected: []string{"foo", "bar", "baz"}, + }, + { + name: "empty values filtered", + input: "foo,,bar,", + expected: []string{"foo", "bar"}, + }, + { + name: "only commas", + input: ",,,", + expected: []string{}, + }, + { + name: "whitespace only values filtered", + input: "foo, ,bar", + expected: []string{"foo", "bar"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseCommaSeparated(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/http/mark/mark.go b/pkg/http/mark/mark.go new file mode 100644 index 0000000000..859a30923d --- /dev/null +++ b/pkg/http/mark/mark.go @@ -0,0 +1,65 @@ +// Package mark provides a mechanism for tagging errors with a well-known error value. +package mark + +import "errors" + +// This list of errors is not exhaustive, but is a good starting point for most +// applications. Feel free to add more as needed, but don't go overboard. +// Remember, the specific types of errors are only important so far as someone +// calling your code might want to write logic to handle each type of error +// differently. +// +// Do not add application-specific errors to this list. Instead, just define +// your own package with your own application-specific errors, and use this +// package to mark errors with them. The errors in this package are not special, +// they're just plain old errors. +// +// Not all errors need to be marked. An error that is not marked should be +// treated as an unexpected error that cannot be handled by calling code. This +// is often the case for network errors or logic errors. +var ( + ErrNotFound = errors.New("not found") + ErrAlreadyExists = errors.New("already exists") + ErrBadRequest = errors.New("bad request") + ErrUnauthorized = errors.New("unauthorized") + ErrCancelled = errors.New("request cancelled") + ErrUnavailable = errors.New("unavailable") + ErrTimedout = errors.New("request timed out") + ErrTooLarge = errors.New("request is too large") + ErrTooManyRequests = errors.New("too many requests") + ErrForbidden = errors.New("forbidden") +) + +// With wraps err with another error that will return true from errors.Is and +// errors.As for both err and markErr, and anything either may wrap. +func With(err, markErr error) error { + if err == nil { + return nil + } + return marked{wrapped: err, mark: markErr} +} + +type marked struct { + wrapped error + mark error +} + +func (f marked) Is(target error) bool { + // if this is false, errors.Is will call unwrap and retry on the wrapped + // error. + return errors.Is(f.mark, target) +} + +func (f marked) As(target any) bool { + // if this is false, errors.As will call unwrap and retry on the wrapped + // error. + return errors.As(f.mark, target) +} + +func (f marked) Unwrap() error { + return f.wrapped +} + +func (f marked) Error() string { + return f.mark.Error() + ": " + f.wrapped.Error() +} 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/middleware/mcp_parse.go b/pkg/http/middleware/mcp_parse.go new file mode 100644 index 0000000000..c82616b270 --- /dev/null +++ b/pkg/http/middleware/mcp_parse.go @@ -0,0 +1,126 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + + ghcontext "github.com/github/github-mcp-server/pkg/context" +) + +// mcpJSONRPCRequest represents the structure of an MCP JSON-RPC request. +// We only parse the fields needed for routing and optimization. +type mcpJSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params struct { + // For tools/call + Name string `json:"name,omitempty"` + Arguments json.RawMessage `json:"arguments,omitempty"` + // For prompts/get + // Name is shared with tools/call + // For resources/read + URI string `json:"uri,omitempty"` + } `json:"params"` +} + +// WithMCPParse creates a middleware that parses MCP JSON-RPC requests early in the +// request lifecycle and stores the parsed information in the request context. +// This enables: +// - Registry filtering via ForMCPRequest (only register needed tools/resources/prompts) +// - Avoiding duplicate JSON parsing in downstream middlewares +// - Access to owner/repo for secret-scanning middleware +// +// The middleware reads the request body, parses it, restores the body for downstream +// handlers, and stores the parsed MCPMethodInfo in the request context. +func WithMCPParse() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Skip health check endpoints + if r.URL.Path == "/_ping" { + next.ServeHTTP(w, r) + return + } + + // Only parse POST requests (MCP uses JSON-RPC over POST) + if r.Method != http.MethodPost { + next.ServeHTTP(w, r) + return + } + + // Read the request body + body, err := io.ReadAll(r.Body) + if err != nil { + // Log but continue - don't block requests on parse errors + next.ServeHTTP(w, r) + return + } + + // Restore the body for downstream handlers + r.Body = io.NopCloser(bytes.NewReader(body)) + + // Skip empty bodies + if len(body) == 0 { + next.ServeHTTP(w, r) + return + } + + // Parse the JSON-RPC request + var mcpReq mcpJSONRPCRequest + err = json.Unmarshal(body, &mcpReq) + if err != nil { + // Log but continue - could be a non-MCP request or malformed JSON + next.ServeHTTP(w, r) + return + } + + // Skip if not a valid JSON-RPC 2.0 request + if mcpReq.JSONRPC != "2.0" || mcpReq.Method == "" { + next.ServeHTTP(w, r) + return + } + + // Build the MCPMethodInfo + methodInfo := &ghcontext.MCPMethodInfo{ + Method: mcpReq.Method, + } + + // Extract item name based on method type + + switch mcpReq.Method { + case "tools/call": + methodInfo.ItemName = mcpReq.Params.Name + // Parse arguments if present + if len(mcpReq.Params.Arguments) > 0 { + var args map[string]any + err := json.Unmarshal(mcpReq.Params.Arguments, &args) + if err == nil { + methodInfo.Arguments = args + // Extract owner and repo if present + if owner, ok := args["owner"].(string); ok { + methodInfo.Owner = owner + } + if repo, ok := args["repo"].(string); ok { + methodInfo.Repo = repo + } + } + } + case "prompts/get": + methodInfo.ItemName = mcpReq.Params.Name + case "resources/read": + methodInfo.ItemName = mcpReq.Params.URI + default: + // Whatever + } + + // Store the parsed info in context + ctx = ghcontext.WithMCPMethodInfo(ctx, methodInfo) + + next.ServeHTTP(w, r.WithContext(ctx)) + } + return http.HandlerFunc(fn) + } +} diff --git a/pkg/http/middleware/mcp_parse_test.go b/pkg/http/middleware/mcp_parse_test.go new file mode 100644 index 0000000000..5a28a30c3b --- /dev/null +++ b/pkg/http/middleware/mcp_parse_test.go @@ -0,0 +1,191 @@ +package middleware + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWithMCPParse(t *testing.T) { + tests := []struct { + name string + method string + path string + body string + expectInfo bool + expectedMethod string + expectedItem string + expectedOwner string + expectedRepo string + expectedArgs map[string]any + }{ + { + name: "health check path is skipped", + method: http.MethodPost, + path: "/_ping", + body: `{"jsonrpc":"2.0","method":"tools/list"}`, + expectInfo: false, + }, + { + name: "GET request is skipped", + method: http.MethodGet, + path: "/mcp", + body: `{"jsonrpc":"2.0","method":"tools/list"}`, + expectInfo: false, + }, + { + name: "empty body is skipped", + method: http.MethodPost, + path: "/mcp", + body: "", + expectInfo: false, + }, + { + name: "invalid JSON is skipped", + method: http.MethodPost, + path: "/mcp", + body: "not valid json", + expectInfo: false, + }, + { + name: "non-JSON-RPC 2.0 is skipped", + method: http.MethodPost, + path: "/mcp", + body: `{"jsonrpc":"1.0","method":"tools/list"}`, + expectInfo: false, + }, + { + name: "empty method is skipped", + method: http.MethodPost, + path: "/mcp", + body: `{"jsonrpc":"2.0","method":""}`, + expectInfo: false, + }, + { + name: "tools/list parses method only", + method: http.MethodPost, + path: "/mcp", + body: `{"jsonrpc":"2.0","method":"tools/list"}`, + expectInfo: true, + expectedMethod: "tools/list", + }, + { + name: "tools/call parses name", + method: http.MethodPost, + path: "/mcp", + body: `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_file_contents"}}`, + expectInfo: true, + expectedMethod: "tools/call", + expectedItem: "get_file_contents", + }, + { + name: "tools/call parses owner and repo from arguments", + method: http.MethodPost, + path: "/mcp", + body: `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_file_contents","arguments":{"owner":"github","repo":"github-mcp-server","path":"README.md"}}}`, + expectInfo: true, + expectedMethod: "tools/call", + expectedItem: "get_file_contents", + expectedOwner: "github", + expectedRepo: "github-mcp-server", + expectedArgs: map[string]any{"owner": "github", "repo": "github-mcp-server", "path": "README.md"}, + }, + { + name: "tools/call with invalid arguments JSON continues without args", + method: http.MethodPost, + path: "/mcp", + body: `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_file_contents","arguments":"not an object"}}`, + expectInfo: true, + expectedMethod: "tools/call", + expectedItem: "get_file_contents", + }, + { + name: "prompts/get parses name", + method: http.MethodPost, + path: "/mcp", + body: `{"jsonrpc":"2.0","method":"prompts/get","params":{"name":"my_prompt"}}`, + expectInfo: true, + expectedMethod: "prompts/get", + expectedItem: "my_prompt", + }, + { + name: "resources/read parses URI as item name", + method: http.MethodPost, + path: "/mcp", + body: `{"jsonrpc":"2.0","method":"resources/read","params":{"uri":"repo://github/github-mcp-server"}}`, + expectInfo: true, + expectedMethod: "resources/read", + expectedItem: "repo://github/github-mcp-server", + }, + { + name: "initialize method parses correctly", + method: http.MethodPost, + path: "/mcp", + body: `{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}}}`, + expectInfo: true, + expectedMethod: "initialize", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var capturedInfo *ghcontext.MCPMethodInfo + var infoCaptured bool + + // Create a handler that captures the MCPMethodInfo from context + nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + capturedInfo, infoCaptured = ghcontext.MCPMethod(r.Context()) + }) + + middleware := WithMCPParse() + handler := middleware(nextHandler) + + req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body)) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if tt.expectInfo { + require.True(t, infoCaptured, "MCPMethodInfo should be present in context") + require.NotNil(t, capturedInfo) + assert.Equal(t, tt.expectedMethod, capturedInfo.Method) + assert.Equal(t, tt.expectedItem, capturedInfo.ItemName) + assert.Equal(t, tt.expectedOwner, capturedInfo.Owner) + assert.Equal(t, tt.expectedRepo, capturedInfo.Repo) + if tt.expectedArgs != nil { + assert.Equal(t, tt.expectedArgs, capturedInfo.Arguments) + } + } else { + assert.False(t, infoCaptured, "MCPMethodInfo should not be present in context") + } + }) + } +} + +func TestWithMCPParse_BodyRestoration(t *testing.T) { + originalBody := `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"test_tool"}}` + + var capturedBody string + + nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + capturedBody = string(body) + }) + + middleware := WithMCPParse() + handler := middleware(nextHandler) + + req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(originalBody)) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + assert.Equal(t, originalBody, capturedBody, "body should be restored for downstream handlers") +} diff --git a/pkg/http/middleware/pat_scope.go b/pkg/http/middleware/pat_scope.go new file mode 100644 index 0000000000..bb1efdc011 --- /dev/null +++ b/pkg/http/middleware/pat_scope.go @@ -0,0 +1,54 @@ +package middleware + +import ( + "log/slog" + "net/http" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/utils" +) + +// WithPATScopes is a middleware that fetches and stores scopes for classic Personal Access Tokens (PATs) in the request context. +func WithPATScopes(logger *slog.Logger, scopeFetcher scopes.FetcherInterface) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + tokenInfo, ok := ghcontext.GetTokenInfo(ctx) + if !ok || tokenInfo == nil { + logger.Warn("no token info found in context") + next.ServeHTTP(w, r) + return + } + + // Fetch token scopes for scope-based tool filtering (PAT tokens only) + // Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header. + // Fine-grained PATs and other token types don't support this, so we skip filtering. + if tokenInfo.TokenType == utils.TokenTypePersonalAccessToken { + existingScopes, ok := ghcontext.GetTokenScopes(ctx) + if ok { + logger.Debug("using existing scopes from context", "scopes", existingScopes) + next.ServeHTTP(w, r) + return + } + + scopesList, err := scopeFetcher.FetchTokenScopes(ctx, tokenInfo.Token) + if err != nil { + logger.Warn("failed to fetch PAT scopes", "error", err) + next.ServeHTTP(w, r) + return + } + + // Store fetched scopes in context for downstream use + ctx = ghcontext.WithTokenScopes(ctx, scopesList) + + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + next.ServeHTTP(w, r) + } + return http.HandlerFunc(fn) + } +} diff --git a/pkg/http/middleware/pat_scope_test.go b/pkg/http/middleware/pat_scope_test.go new file mode 100644 index 0000000000..0607b8cf2b --- /dev/null +++ b/pkg/http/middleware/pat_scope_test.go @@ -0,0 +1,190 @@ +package middleware + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockScopeFetcher is a mock implementation of scopes.FetcherInterface +type mockScopeFetcher struct { + scopes []string + err error +} + +func (m *mockScopeFetcher) FetchTokenScopes(_ context.Context, _ string) ([]string, error) { + return m.scopes, m.err +} + +func TestWithPATScopes(t *testing.T) { + logger := slog.Default() + + tests := []struct { + name string + tokenInfo *ghcontext.TokenInfo + fetcherScopes []string + fetcherErr error + expectScopesFetched bool + expectedScopes []string + expectNextHandlerCalled bool + }{ + { + name: "no token info in context calls next handler", + tokenInfo: nil, + expectScopesFetched: false, + expectedScopes: nil, + expectNextHandlerCalled: true, + }, + { + name: "non-PAT token type skips scope fetching", + tokenInfo: &ghcontext.TokenInfo{ + Token: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + TokenType: utils.TokenTypeOAuthAccessToken, + }, + expectScopesFetched: false, + expectedScopes: nil, + expectNextHandlerCalled: true, + }, + { + name: "fine-grained PAT skips scope fetching", + tokenInfo: &ghcontext.TokenInfo{ + Token: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx", + TokenType: utils.TokenTypeFineGrainedPersonalAccessToken, + }, + expectScopesFetched: false, + expectedScopes: nil, + expectNextHandlerCalled: true, + }, + { + name: "classic PAT fetches and stores scopes", + tokenInfo: &ghcontext.TokenInfo{ + Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + TokenType: utils.TokenTypePersonalAccessToken, + }, + fetcherScopes: []string{"repo", "user", "read:org"}, + expectScopesFetched: true, + expectedScopes: []string{"repo", "user", "read:org"}, + expectNextHandlerCalled: true, + }, + { + name: "classic PAT with empty scopes", + tokenInfo: &ghcontext.TokenInfo{ + Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + TokenType: utils.TokenTypePersonalAccessToken, + }, + fetcherScopes: []string{}, + expectScopesFetched: true, + expectedScopes: []string{}, + expectNextHandlerCalled: true, + }, + { + name: "fetcher error calls next handler without scopes", + tokenInfo: &ghcontext.TokenInfo{ + Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + TokenType: utils.TokenTypePersonalAccessToken, + }, + fetcherErr: errors.New("network error"), + expectScopesFetched: false, + expectedScopes: nil, + expectNextHandlerCalled: true, + }, + { + name: "old-style PAT (40 hex chars) fetches scopes", + tokenInfo: &ghcontext.TokenInfo{ + Token: "0123456789abcdef0123456789abcdef01234567", + TokenType: utils.TokenTypePersonalAccessToken, + }, + fetcherScopes: []string{"repo"}, + expectScopesFetched: true, + expectedScopes: []string{"repo"}, + expectNextHandlerCalled: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var capturedScopes []string + var scopesFound bool + var nextHandlerCalled bool + + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextHandlerCalled = true + capturedScopes, scopesFound = ghcontext.GetTokenScopes(r.Context()) + w.WriteHeader(http.StatusOK) + }) + + fetcher := &mockScopeFetcher{ + scopes: tt.fetcherScopes, + err: tt.fetcherErr, + } + + middleware := WithPATScopes(logger, fetcher) + handler := middleware(nextHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + + // Set up context with token info if provided + if tt.tokenInfo != nil { + ctx := ghcontext.WithTokenInfo(req.Context(), tt.tokenInfo) + req = req.WithContext(ctx) + } + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.expectNextHandlerCalled, nextHandlerCalled, "next handler called mismatch") + + if tt.expectNextHandlerCalled { + assert.Equal(t, tt.expectScopesFetched, scopesFound, "scopes found mismatch") + assert.Equal(t, tt.expectedScopes, capturedScopes) + } + }) + } +} + +func TestWithPATScopes_PreservesExistingTokenInfo(t *testing.T) { + logger := slog.Default() + + var capturedTokenInfo *ghcontext.TokenInfo + var capturedScopes []string + var scopesFound bool + + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedTokenInfo, _ = ghcontext.GetTokenInfo(r.Context()) + capturedScopes, scopesFound = ghcontext.GetTokenScopes(r.Context()) + w.WriteHeader(http.StatusOK) + }) + + fetcher := &mockScopeFetcher{ + scopes: []string{"repo", "user"}, + } + + originalTokenInfo := &ghcontext.TokenInfo{ + Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + TokenType: utils.TokenTypePersonalAccessToken, + } + + middleware := WithPATScopes(logger, fetcher) + handler := middleware(nextHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + ctx := ghcontext.WithTokenInfo(req.Context(), originalTokenInfo) + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + require.NotNil(t, capturedTokenInfo) + assert.Equal(t, originalTokenInfo.Token, capturedTokenInfo.Token) + assert.Equal(t, originalTokenInfo.TokenType, capturedTokenInfo.TokenType) + assert.True(t, scopesFound) + assert.Equal(t, []string{"repo", "user"}, capturedScopes) +} diff --git a/pkg/http/middleware/request_config.go b/pkg/http/middleware/request_config.go new file mode 100644 index 0000000000..a7311334d3 --- /dev/null +++ b/pkg/http/middleware/request_config.go @@ -0,0 +1,64 @@ +package middleware + +import ( + "net/http" + "slices" + "strings" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/http/headers" +) + +// WithRequestConfig is a middleware that extracts MCP-related headers and sets them in the request context. +// This includes readonly mode, toolsets, tools, lockdown mode, insiders mode, and feature flags. +func WithRequestConfig(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Readonly mode + if relaxedParseBool(r.Header.Get(headers.MCPReadOnlyHeader)) { + ctx = ghcontext.WithReadonly(ctx, true) + } + + // Toolsets + if toolsets := headers.ParseCommaSeparated(r.Header.Get(headers.MCPToolsetsHeader)); len(toolsets) > 0 { + ctx = ghcontext.WithToolsets(ctx, toolsets) + } + + // Tools + if tools := headers.ParseCommaSeparated(r.Header.Get(headers.MCPToolsHeader)); len(tools) > 0 { + ctx = ghcontext.WithTools(ctx, tools) + } + + // Lockdown mode + if relaxedParseBool(r.Header.Get(headers.MCPLockdownHeader)) { + ctx = ghcontext.WithLockdownMode(ctx, true) + } + + // Excluded tools + if excludeTools := headers.ParseCommaSeparated(r.Header.Get(headers.MCPExcludeToolsHeader)); len(excludeTools) > 0 { + ctx = ghcontext.WithExcludeTools(ctx, excludeTools) + } + + // Insiders mode + if relaxedParseBool(r.Header.Get(headers.MCPInsidersHeader)) { + ctx = ghcontext.WithInsidersMode(ctx, true) + } + + // Feature flags + if features := headers.ParseCommaSeparated(r.Header.Get(headers.MCPFeaturesHeader)); len(features) > 0 { + ctx = ghcontext.WithHeaderFeatures(ctx, features) + } + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// relaxedParseBool parses a string into a boolean value, treating various +// common false values or empty strings as false, and everything else as true. +// It is case-insensitive and trims whitespace. +func relaxedParseBool(s string) bool { + s = strings.TrimSpace(strings.ToLower(s)) + falseValues := []string{"", "false", "0", "no", "off", "n", "f"} + return !slices.Contains(falseValues, s) +} diff --git a/pkg/http/middleware/scope_challenge.go b/pkg/http/middleware/scope_challenge.go new file mode 100644 index 0000000000..1a86bf93ce --- /dev/null +++ b/pkg/http/middleware/scope_challenge.go @@ -0,0 +1,145 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/http/oauth" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/utils" +) + +// WithScopeChallenge creates a new middleware that determines if an OAuth request contains sufficient scopes to +// complete the request and returns a scope challenge if not. +func WithScopeChallenge(oauthCfg *oauth.Config, scopeFetcher scopes.FetcherInterface) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Skip health check endpoints + if r.URL.Path == "/_ping" { + next.ServeHTTP(w, r) + return + } + + // Get user from context + tokenInfo, ok := ghcontext.GetTokenInfo(ctx) + if !ok { + next.ServeHTTP(w, r) + return + } + + // Only check OAuth tokens - scope challenge allows OAuth apps to request additional scopes + if tokenInfo.TokenType != utils.TokenTypeOAuthAccessToken { + next.ServeHTTP(w, r) + return + } + + // Try to use pre-parsed MCP method info first (performance optimization) + // This avoids re-parsing the JSON body if WithMCPParse middleware ran earlier + var toolName string + if methodInfo, ok := ghcontext.MCPMethod(ctx); ok && methodInfo != nil { + // Only check tools/call requests + if methodInfo.Method != "tools/call" { + next.ServeHTTP(w, r) + return + } + toolName = methodInfo.ItemName + } else { + // Fallback: parse the request body directly + body, err := io.ReadAll(r.Body) + if err != nil { + next.ServeHTTP(w, r) + return + } + r.Body = io.NopCloser(bytes.NewReader(body)) + + var mcpRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params struct { + Name string `json:"name,omitempty"` + Arguments map[string]any `json:"arguments,omitempty"` + } `json:"params"` + } + + err = json.Unmarshal(body, &mcpRequest) + if err != nil { + next.ServeHTTP(w, r) + return + } + + // Only check tools/call requests + if mcpRequest.Method != "tools/call" { + next.ServeHTTP(w, r) + return + } + + toolName = mcpRequest.Params.Name + } + toolScopeInfo, err := scopes.GetToolScopeInfo(toolName) + if err != nil { + next.ServeHTTP(w, r) + return + } + + // If tool not found in scope map, allow the request + if toolScopeInfo == nil { + next.ServeHTTP(w, r) + return + } + + // Get OAuth scopes for Token. First check if scopes are already in context, then fetch from GitHub if not present. + // This allows Remote Server to pass scope info to avoid redundant GitHub API calls. + activeScopes, ok := ghcontext.GetTokenScopes(ctx) + if !ok || (len(activeScopes) == 0 && tokenInfo.Token != "") { + activeScopes, err = scopeFetcher.FetchTokenScopes(ctx, tokenInfo.Token) + if err != nil { + next.ServeHTTP(w, r) + return + } + } + + // Store active scopes in context for downstream use + ctx = ghcontext.WithTokenScopes(ctx, activeScopes) + r = r.WithContext(ctx) + + // Check if user has the required scopes + if toolScopeInfo.HasAcceptedScope(activeScopes...) { + next.ServeHTTP(w, r) + return + } + + // User lacks required scopes - get the scopes they need + requiredScopes := toolScopeInfo.GetRequiredScopesSlice() + + // Build the resource metadata URL using the shared utility + // GetEffectiveResourcePath returns the original path (e.g., /mcp or /mcp/x/all) + // which is used to construct the well-known OAuth protected resource URL + resourcePath := oauth.ResolveResourcePath(r, oauthCfg) + resourceMetadataURL := oauth.BuildResourceMetadataURL(r, oauthCfg, resourcePath) + + // Build recommended scopes: existing scopes + required scopes + recommendedScopes := make([]string, 0, len(activeScopes)+len(requiredScopes)) + recommendedScopes = append(recommendedScopes, activeScopes...) + recommendedScopes = append(recommendedScopes, requiredScopes...) + + // Build the WWW-Authenticate header value + wwwAuthenticateHeader := fmt.Sprintf(`Bearer error="insufficient_scope", scope=%q, resource_metadata=%q, error_description=%q`, + strings.Join(recommendedScopes, " "), + resourceMetadataURL, + "Additional scopes required: "+strings.Join(requiredScopes, ", "), + ) + + // Send scope challenge response with the superset of existing and required scopes + w.Header().Set("WWW-Authenticate", wwwAuthenticateHeader) + http.Error(w, "Forbidden: insufficient scopes", http.StatusForbidden) + } + return http.HandlerFunc(fn) + } +} diff --git a/pkg/http/middleware/token.go b/pkg/http/middleware/token.go new file mode 100644 index 0000000000..012bbabef2 --- /dev/null +++ b/pkg/http/middleware/token.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "errors" + "fmt" + "net/http" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/http/oauth" + "github.com/github/github-mcp-server/pkg/utils" +) + +func ExtractUserToken(oauthCfg *oauth.Config) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Check if token info already exists in context, if it does, skip extraction. + // In remote setup, we may have already extracted token info earlier. + if _, ok := ghcontext.GetTokenInfo(ctx); ok { + // Token info already exists in context, skip extraction + next.ServeHTTP(w, r) + return + } + + tokenType, token, err := utils.ParseAuthorizationHeader(r) + if err != nil { + // For missing Authorization header, return 401 with WWW-Authenticate header per MCP spec + if errors.Is(err, utils.ErrMissingAuthorizationHeader) { + sendAuthChallenge(w, r, oauthCfg) + return + } + // For other auth errors (bad format, unsupported), return 400 + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + ctx = ghcontext.WithTokenInfo(ctx, &ghcontext.TokenInfo{ + Token: token, + TokenType: tokenType, + }) + r = r.WithContext(ctx) + + next.ServeHTTP(w, r) + }) + } +} + +// sendAuthChallenge sends a 401 Unauthorized response with WWW-Authenticate header +// containing the OAuth protected resource metadata URL as per RFC 6750 and MCP spec. +func sendAuthChallenge(w http.ResponseWriter, r *http.Request, oauthCfg *oauth.Config) { + resourcePath := oauth.ResolveResourcePath(r, oauthCfg) + resourceMetadataURL := oauth.BuildResourceMetadataURL(r, oauthCfg, resourcePath) + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata=%q`, resourceMetadataURL)) + http.Error(w, "Unauthorized", http.StatusUnauthorized) +} diff --git a/pkg/http/middleware/token_test.go b/pkg/http/middleware/token_test.go new file mode 100644 index 0000000000..fa8f0ee98e --- /dev/null +++ b/pkg/http/middleware/token_test.go @@ -0,0 +1,321 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/pkg/http/oauth" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractUserToken(t *testing.T) { + oauthCfg := &oauth.Config{ + BaseURL: "https://example.com", + AuthorizationServer: "https://github.com/login/oauth", + } + + tests := []struct { + name string + authHeader string + expectedStatusCode int + expectedTokenType utils.TokenType + expectedToken string + expectTokenInfo bool + expectWWWAuth bool + }{ + // Missing authorization header + { + name: "missing Authorization header returns 401 with WWW-Authenticate", + authHeader: "", + expectedStatusCode: http.StatusUnauthorized, + expectTokenInfo: false, + expectWWWAuth: true, + }, + // Personal Access Token (classic) - ghp_ prefix + { + name: "personal access token (classic) with Bearer prefix", + authHeader: "Bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectedStatusCode: http.StatusOK, + expectedTokenType: utils.TokenTypePersonalAccessToken, + expectedToken: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectTokenInfo: true, + }, + { + name: "personal access token (classic) with bearer lowercase", + authHeader: "bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectedStatusCode: http.StatusOK, + expectedTokenType: utils.TokenTypePersonalAccessToken, + expectedToken: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectTokenInfo: true, + }, + { + name: "personal access token (classic) without Bearer prefix", + authHeader: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectedStatusCode: http.StatusOK, + expectedTokenType: utils.TokenTypePersonalAccessToken, + expectedToken: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectTokenInfo: true, + }, + // Fine-grained Personal Access Token - github_pat_ prefix + { + name: "fine-grained personal access token with Bearer prefix", + authHeader: "Bearer github_pat_xxxxxxxxxxxxxxxxxxxxxxx", + expectedStatusCode: http.StatusOK, + expectedTokenType: utils.TokenTypeFineGrainedPersonalAccessToken, + expectedToken: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx", + expectTokenInfo: true, + }, + { + name: "fine-grained personal access token without Bearer prefix", + authHeader: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx", + expectedStatusCode: http.StatusOK, + expectedTokenType: utils.TokenTypeFineGrainedPersonalAccessToken, + expectedToken: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx", + expectTokenInfo: true, + }, + // OAuth Access Token - gho_ prefix + { + name: "OAuth access token with Bearer prefix", + authHeader: "Bearer gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectedStatusCode: http.StatusOK, + expectedTokenType: utils.TokenTypeOAuthAccessToken, + expectedToken: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectTokenInfo: true, + }, + { + name: "OAuth access token without Bearer prefix", + authHeader: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectedStatusCode: http.StatusOK, + expectedTokenType: utils.TokenTypeOAuthAccessToken, + expectedToken: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectTokenInfo: true, + }, + // User-to-Server GitHub App Token - ghu_ prefix + { + name: "user-to-server GitHub App token with Bearer prefix", + authHeader: "Bearer ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectedStatusCode: http.StatusOK, + expectedTokenType: utils.TokenTypeUserToServerGitHubAppToken, + expectedToken: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectTokenInfo: true, + }, + { + name: "user-to-server GitHub App token without Bearer prefix", + authHeader: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectedStatusCode: http.StatusOK, + expectedTokenType: utils.TokenTypeUserToServerGitHubAppToken, + expectedToken: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectTokenInfo: true, + }, + // Server-to-Server GitHub App Token (installation token) - ghs_ prefix + { + name: "server-to-server GitHub App token with Bearer prefix", + authHeader: "Bearer ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectedStatusCode: http.StatusOK, + expectedTokenType: utils.TokenTypeServerToServerGitHubAppToken, + expectedToken: "ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectTokenInfo: true, + }, + { + name: "server-to-server GitHub App token without Bearer prefix", + authHeader: "ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectedStatusCode: http.StatusOK, + expectedTokenType: utils.TokenTypeServerToServerGitHubAppToken, + expectedToken: "ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expectTokenInfo: true, + }, + // Old-style Personal Access Token (40 hex characters, pre-2021) + { + name: "old-style personal access token (40 hex chars) with Bearer prefix", + authHeader: "Bearer 0123456789abcdef0123456789abcdef01234567", + expectedStatusCode: http.StatusOK, + expectedTokenType: utils.TokenTypePersonalAccessToken, + expectedToken: "0123456789abcdef0123456789abcdef01234567", + expectTokenInfo: true, + }, + { + name: "old-style personal access token (40 hex chars) without Bearer prefix", + authHeader: "0123456789abcdef0123456789abcdef01234567", + expectedStatusCode: http.StatusOK, + expectedTokenType: utils.TokenTypePersonalAccessToken, + expectedToken: "0123456789abcdef0123456789abcdef01234567", + expectTokenInfo: true, + }, + // Error cases + { + name: "unsupported GitHub-Bearer header returns 400", + authHeader: "GitHub-Bearer some_encrypted_token", + expectedStatusCode: http.StatusBadRequest, + expectTokenInfo: false, + }, + { + name: "invalid token format returns 400", + authHeader: "Bearer invalid_token_format", + expectedStatusCode: http.StatusBadRequest, + expectTokenInfo: false, + }, + { + name: "unrecognized prefix returns 400", + authHeader: "Bearer xyz_notavalidprefix", + expectedStatusCode: http.StatusBadRequest, + expectTokenInfo: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var capturedTokenInfo *ghcontext.TokenInfo + var tokenInfoCaptured bool + + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedTokenInfo, tokenInfoCaptured = ghcontext.GetTokenInfo(r.Context()) + w.WriteHeader(http.StatusOK) + }) + + middleware := ExtractUserToken(oauthCfg) + handler := middleware(nextHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + if tt.authHeader != "" { + req.Header.Set(headers.AuthorizationHeader, tt.authHeader) + } + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.expectedStatusCode, rr.Code) + + if tt.expectWWWAuth { + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.NotEmpty(t, wwwAuth, "expected WWW-Authenticate header") + assert.Contains(t, wwwAuth, "Bearer resource_metadata=") + } + + if tt.expectTokenInfo { + require.True(t, tokenInfoCaptured, "expected TokenInfo to be present in context") + require.NotNil(t, capturedTokenInfo) + assert.Equal(t, tt.expectedTokenType, capturedTokenInfo.TokenType) + assert.Equal(t, tt.expectedToken, capturedTokenInfo.Token) + } else { + assert.False(t, tokenInfoCaptured, "expected no TokenInfo in context") + } + }) + } +} + +func TestExtractUserToken_NilOAuthConfig(t *testing.T) { + var capturedTokenInfo *ghcontext.TokenInfo + var tokenInfoCaptured bool + + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedTokenInfo, tokenInfoCaptured = ghcontext.GetTokenInfo(r.Context()) + w.WriteHeader(http.StatusOK) + }) + + middleware := ExtractUserToken(nil) + handler := middleware(nextHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + require.True(t, tokenInfoCaptured) + require.NotNil(t, capturedTokenInfo) + assert.Equal(t, utils.TokenTypePersonalAccessToken, capturedTokenInfo.TokenType) +} + +func TestExtractUserToken_MissingAuthHeader_WWWAuthenticateFormat(t *testing.T) { + oauthCfg := &oauth.Config{ + BaseURL: "https://api.example.com", + AuthorizationServer: "https://github.com/login/oauth", + ResourcePath: "/mcp", + } + + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + middleware := ExtractUserToken(oauthCfg) + handler := middleware(nextHandler) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + // No Authorization header + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.NotEmpty(t, wwwAuth) + assert.Contains(t, wwwAuth, "Bearer") + assert.Contains(t, wwwAuth, "resource_metadata=") + assert.Contains(t, wwwAuth, "/.well-known/oauth-protected-resource") +} + +func TestSendAuthChallenge(t *testing.T) { + tests := []struct { + name string + oauthCfg *oauth.Config + requestPath string + expectedContains []string + }{ + { + name: "with base URL configured", + oauthCfg: &oauth.Config{ + BaseURL: "https://mcp.example.com", + }, + requestPath: "/api/test", + expectedContains: []string{ + "Bearer", + "resource_metadata=", + "https://mcp.example.com/.well-known/oauth-protected-resource", + }, + }, + { + name: "with nil config uses request host", + oauthCfg: nil, + requestPath: "/api/test", + expectedContains: []string{ + "Bearer", + "resource_metadata=", + "/.well-known/oauth-protected-resource", + }, + }, + { + name: "with resource path configured", + oauthCfg: &oauth.Config{ + BaseURL: "https://mcp.example.com", + ResourcePath: "/mcp", + }, + requestPath: "/api/test", + expectedContains: []string{ + "Bearer", + "resource_metadata=", + "/mcp", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, tt.requestPath, nil) + + sendAuthChallenge(rr, req, tt.oauthCfg) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) + wwwAuth := rr.Header().Get("WWW-Authenticate") + for _, expected := range tt.expectedContains { + assert.Contains(t, wwwAuth, expected) + } + }) + } +} diff --git a/pkg/http/oauth/oauth.go b/pkg/http/oauth/oauth.go new file mode 100644 index 0000000000..ffa7669a9d --- /dev/null +++ b/pkg/http/oauth/oauth.go @@ -0,0 +1,281 @@ +// Package oauth provides OAuth 2.0 Protected Resource Metadata (RFC 9728) support +// for the GitHub MCP Server HTTP mode. +package oauth + +import ( + "fmt" + "net/http" + "strings" + + "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/go-chi/chi/v5" + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/oauthex" +) + +const ( + // OAuthProtectedResourcePrefix is the well-known path prefix for OAuth protected resource metadata. + OAuthProtectedResourcePrefix = "/.well-known/oauth-protected-resource" +) + +// SupportedScopes lists all OAuth scopes that may be required by MCP tools. +var SupportedScopes = []string{ + "repo", + "read:org", + "read:user", + "user:email", + "read:packages", + "write:packages", + "read:project", + "project", + "gist", + "notifications", + "workflow", + "codespace", +} + +// Config holds the OAuth configuration for the MCP server. +type Config struct { + // BaseURL is the publicly accessible URL where this server is hosted. + // This is used to construct the OAuth resource URL. + BaseURL string + + // AuthorizationServer is the OAuth authorization server URL. + // Defaults to GitHub's OAuth server if not specified. + AuthorizationServer string + + // ResourcePath is the externally visible base path for the MCP server (e.g., "/mcp"). + // 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. +type AuthHandler struct { + cfg *Config + apiHost utils.APIHostResolver +} + +// NewAuthHandler creates a new OAuth auth handler. +func NewAuthHandler(cfg *Config, apiHost utils.APIHostResolver) (*AuthHandler, error) { + if cfg == nil { + cfg = &Config{} + } + + if apiHost == nil { + var err error + apiHost, err = utils.NewAPIHost("https://api.github.com") + if err != nil { + return nil, fmt.Errorf("failed to create default API host: %w", err) + } + } + + return &AuthHandler{ + cfg: cfg, + apiHost: apiHost, + }, nil +} + +// routePatterns defines the route patterns for OAuth protected resource metadata. +var routePatterns = []string{ + "", // Root: /.well-known/oauth-protected-resource + "/readonly", // Read-only mode + "/insiders", // Insiders mode + "/x/{toolset}", + "/x/{toolset}/readonly", +} + +// RegisterRoutes registers the OAuth protected resource metadata routes. +func (h *AuthHandler) RegisterRoutes(r chi.Router) { + for _, pattern := range routePatterns { + for _, route := range h.routesForPattern(pattern) { + path := OAuthProtectedResourcePrefix + route + r.Handle(path, h.metadataHandler()) + } + } +} + +func (h *AuthHandler) metadataHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + resourcePath := resolveResourcePath( + strings.TrimPrefix(r.URL.Path, OAuthProtectedResourcePrefix), + h.cfg.ResourcePath, + ) + resourceURL := h.buildResourceURL(r, resourcePath) + + var authorizationServerURL string + if h.cfg.AuthorizationServer != "" { + authorizationServerURL = h.cfg.AuthorizationServer + } else { + authURL, err := h.apiHost.AuthorizationServerURL(ctx) + if err != nil { + http.Error(w, fmt.Sprintf("failed to resolve authorization server URL: %v", err), http.StatusInternalServerError) + return + } + authorizationServerURL = authURL.String() + } + + metadata := &oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authorizationServerURL}, + ResourceName: "GitHub MCP Server", + ScopesSupported: SupportedScopes, + BearerMethodsSupported: []string{"header"}, + } + + auth.ProtectedResourceMetadataHandler(metadata).ServeHTTP(w, r) + }) +} + +// routesForPattern generates route variants for a given pattern. +// GitHub strips the /mcp prefix before forwarding, so we register both variants: +// - With /mcp prefix: for direct access or when GitHub doesn't strip +// - Without /mcp prefix: for when GitHub has stripped the prefix +func (h *AuthHandler) routesForPattern(pattern string) []string { + basePaths := []string{""} + if basePath := normalizeBasePath(h.cfg.ResourcePath); basePath != "" { + basePaths = append(basePaths, basePath) + } else { + basePaths = append(basePaths, "/mcp") + } + + routes := make([]string, 0, len(basePaths)*2) + for _, basePath := range basePaths { + routes = append(routes, joinRoute(basePath, pattern)) + routes = append(routes, joinRoute(basePath, pattern)+"/") + } + + return routes +} + +// resolveResourcePath returns the externally visible resource path, +// restoring the configured base path when proxies strip it before forwarding. +func resolveResourcePath(path, basePath string) string { + if path == "" { + path = "/" + } + base := normalizeBasePath(basePath) + if base == "" { + return path + } + if path == "/" { + return base + } + if path == base || strings.HasPrefix(path, base+"/") { + return path + } + return base + path +} + +// ResolveResourcePath returns the externally visible resource path for a request. +// Exported for use by middleware. +func ResolveResourcePath(r *http.Request, cfg *Config) string { + basePath := "" + if cfg != nil { + basePath = cfg.ResourcePath + } + return resolveResourcePath(r.URL.Path, basePath) +} + +// buildResourceURL constructs the full resource URL for OAuth metadata. +func (h *AuthHandler) buildResourceURL(r *http.Request, resourcePath string) string { + host, scheme := GetEffectiveHostAndScheme(r, h.cfg) + baseURL := fmt.Sprintf("%s://%s", scheme, host) + if h.cfg.BaseURL != "" { + baseURL = strings.TrimSuffix(h.cfg.BaseURL, "/") + } + if resourcePath == "" { + resourcePath = "/" + } + if !strings.HasPrefix(resourcePath, "/") { + resourcePath = "/" + resourcePath + } + return baseURL + resourcePath +} + +// 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 + 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 trustProxy { + if fp := r.Header.Get(headers.ForwardedProtoHeader); fp != "" { + scheme = strings.ToLower(fp) + } + } + if scheme == "" { + if r.TLS != nil { + scheme = "https" + } else { + scheme = "http" + } + } + return +} + +// BuildResourceMetadataURL constructs the full URL to the OAuth protected resource metadata endpoint. +func BuildResourceMetadataURL(r *http.Request, cfg *Config, resourcePath string) string { + host, scheme := GetEffectiveHostAndScheme(r, cfg) + suffix := "" + if resourcePath != "" && resourcePath != "/" { + if !strings.HasPrefix(resourcePath, "/") { + suffix = "/" + resourcePath + } else { + suffix = resourcePath + } + } + if cfg != nil && cfg.BaseURL != "" { + return strings.TrimSuffix(cfg.BaseURL, "/") + OAuthProtectedResourcePrefix + suffix + } + return fmt.Sprintf("%s://%s%s%s", scheme, host, OAuthProtectedResourcePrefix, suffix) +} + +func normalizeBasePath(path string) string { + trimmed := strings.TrimSpace(path) + if trimmed == "" || trimmed == "/" { + return "" + } + if !strings.HasPrefix(trimmed, "/") { + trimmed = "/" + trimmed + } + return strings.TrimSuffix(trimmed, "/") +} + +func joinRoute(basePath, pattern string) string { + if basePath == "" { + return pattern + } + if pattern == "" { + return basePath + } + if before, ok := strings.CutSuffix(basePath, "/"); ok { + return before + pattern + } + return basePath + pattern +} diff --git a/pkg/http/oauth/oauth_test.go b/pkg/http/oauth/oauth_test.go new file mode 100644 index 0000000000..f39ef39b87 --- /dev/null +++ b/pkg/http/oauth/oauth_test.go @@ -0,0 +1,763 @@ +package oauth + +import ( + "crypto/tls" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + defaultAuthorizationServer = "https://github.com/login/oauth" +) + +func TestNewAuthHandler(t *testing.T) { + t.Parallel() + + dotcomHost, err := utils.NewAPIHost("https://api.github.com") + require.NoError(t, err) + + tests := []struct { + name string + cfg *Config + expectedAuthServer string + expectedResourcePath string + }{ + { + name: "custom authorization server", + cfg: &Config{ + AuthorizationServer: "https://custom.example.com/oauth", + }, + expectedAuthServer: "https://custom.example.com/oauth", + expectedResourcePath: "", + }, + { + name: "custom base URL and resource path", + cfg: &Config{ + BaseURL: "https://example.com", + ResourcePath: "/mcp", + }, + expectedAuthServer: "", + expectedResourcePath: "/mcp", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + handler, err := NewAuthHandler(tc.cfg, dotcomHost) + require.NoError(t, err) + require.NotNil(t, handler) + + assert.Equal(t, tc.expectedAuthServer, handler.cfg.AuthorizationServer) + assert.Equal(t, tc.expectedResourcePath, handler.cfg.ResourcePath) + }) + } +} + +func TestGetEffectiveHostAndScheme(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupRequest func() *http.Request + cfg *Config + expectedHost string + expectedScheme string + }{ + { + name: "basic request without forwarding headers", + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Host = "example.com" + return req + }, + cfg: &Config{}, + 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 { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Host = "internal.example.com" + req.Header.Set(headers.ForwardedHostHeader, "public.example.com") + return req + }, + cfg: &Config{TrustProxyHeaders: true}, + expectedHost: "public.example.com", + expectedScheme: "http", + }, + { + name: "request with X-Forwarded-Proto header", + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Host = "example.com" + req.Header.Set(headers.ForwardedProtoHeader, "http") + return req + }, + cfg: &Config{TrustProxyHeaders: true}, + expectedHost: "example.com", + expectedScheme: "http", + }, + { + name: "request with both forwarding headers", + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Host = "internal.example.com" + req.Header.Set(headers.ForwardedHostHeader, "public.example.com") + req.Header.Set(headers.ForwardedProtoHeader, "https") + return req + }, + cfg: &Config{TrustProxyHeaders: true}, + expectedHost: "public.example.com", + expectedScheme: "https", + }, + { + name: "request with TLS", + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Host = "example.com" + req.TLS = &tls.ConnectionState{} + return req + }, + cfg: &Config{}, + expectedHost: "example.com", + expectedScheme: "https", + }, + { + name: "X-Forwarded-Proto takes precedence over TLS", + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Host = "example.com" + req.TLS = &tls.ConnectionState{} + req.Header.Set(headers.ForwardedProtoHeader, "http") + return req + }, + cfg: &Config{TrustProxyHeaders: true}, + expectedHost: "example.com", + expectedScheme: "http", + }, + { + name: "scheme is lowercased", + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Host = "example.com" + req.Header.Set(headers.ForwardedProtoHeader, "HTTPS") + return req + }, + cfg: &Config{TrustProxyHeaders: true}, + expectedHost: "example.com", + expectedScheme: "https", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req := tc.setupRequest() + host, scheme := GetEffectiveHostAndScheme(req, tc.cfg) + + assert.Equal(t, tc.expectedHost, host) + assert.Equal(t, tc.expectedScheme, scheme) + }) + } +} + +func TestResolveResourcePath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg *Config + setupRequest func() *http.Request + expectedPath string + }{ + { + name: "no base path uses request path", + cfg: &Config{}, + setupRequest: func() *http.Request { + return httptest.NewRequest(http.MethodGet, "/x/repos", nil) + }, + expectedPath: "/x/repos", + }, + { + name: "base path restored for root", + cfg: &Config{ + ResourcePath: "/mcp", + }, + setupRequest: func() *http.Request { + return httptest.NewRequest(http.MethodGet, "/", nil) + }, + expectedPath: "/mcp", + }, + { + name: "base path restored for nested", + cfg: &Config{ + ResourcePath: "/mcp", + }, + setupRequest: func() *http.Request { + return httptest.NewRequest(http.MethodGet, "/readonly", nil) + }, + expectedPath: "/mcp/readonly", + }, + { + name: "base path preserved when already present", + cfg: &Config{ + ResourcePath: "/mcp", + }, + setupRequest: func() *http.Request { + return httptest.NewRequest(http.MethodGet, "/mcp/readonly/", nil) + }, + expectedPath: "/mcp/readonly/", + }, + { + name: "custom base path restored", + cfg: &Config{ + ResourcePath: "/api", + }, + setupRequest: func() *http.Request { + return httptest.NewRequest(http.MethodGet, "/x/repos", nil) + }, + expectedPath: "/api/x/repos", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req := tc.setupRequest() + path := ResolveResourcePath(req, tc.cfg) + + assert.Equal(t, tc.expectedPath, path) + }) + } +} + +func TestBuildResourceMetadataURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg *Config + setupRequest func() *http.Request + resourcePath string + expectedURL string + }{ + { + name: "root path", + cfg: &Config{}, + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = "api.example.com" + return req + }, + resourcePath: "/", + expectedURL: "http://api.example.com/.well-known/oauth-protected-resource", + }, + { + name: "resource path preserves trailing slash", + cfg: &Config{}, + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/mcp/", nil) + req.Host = "api.example.com" + return req + }, + resourcePath: "/mcp/", + expectedURL: "http://api.example.com/.well-known/oauth-protected-resource/mcp/", + }, + { + name: "with custom resource path", + cfg: &Config{}, + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/mcp", nil) + req.Host = "api.example.com" + return req + }, + resourcePath: "/mcp", + expectedURL: "http://api.example.com/.well-known/oauth-protected-resource/mcp", + }, + { + name: "with base URL config", + cfg: &Config{ + BaseURL: "https://custom.example.com", + }, + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/mcp", nil) + req.Host = "api.example.com" + return req + }, + resourcePath: "/mcp", + expectedURL: "https://custom.example.com/.well-known/oauth-protected-resource/mcp", + }, + { + 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" + req.Header.Set(headers.ForwardedHostHeader, "public.example.com") + req.Header.Set(headers.ForwardedProtoHeader, "https") + return req + }, + resourcePath: "/mcp", + expectedURL: "https://public.example.com/.well-known/oauth-protected-resource/mcp", + }, + { + name: "nil config uses request host", + cfg: nil, + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = "api.example.com" + return req + }, + resourcePath: "", + expectedURL: "http://api.example.com/.well-known/oauth-protected-resource", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req := tc.setupRequest() + url := BuildResourceMetadataURL(req, tc.cfg, tc.resourcePath) + + assert.Equal(t, tc.expectedURL, url) + }) + } +} + +func TestHandleProtectedResource(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg *Config + path string + host string + method string + expectedStatusCode int + expectedScopes []string + validateResponse func(t *testing.T, body map[string]any) + }{ + { + name: "GET request returns protected resource metadata", + cfg: &Config{ + BaseURL: "https://api.example.com", + }, + path: OAuthProtectedResourcePrefix, + host: "api.example.com", + method: http.MethodGet, + expectedStatusCode: http.StatusOK, + expectedScopes: SupportedScopes, + validateResponse: func(t *testing.T, body map[string]any) { + t.Helper() + assert.Equal(t, "GitHub MCP Server", body["resource_name"]) + assert.Equal(t, "https://api.example.com/", body["resource"]) + + authServers, ok := body["authorization_servers"].([]any) + require.True(t, ok) + require.Len(t, authServers, 1) + assert.Equal(t, defaultAuthorizationServer, authServers[0]) + }, + }, + { + name: "OPTIONS request for CORS preflight", + cfg: &Config{ + BaseURL: "https://api.example.com", + }, + path: OAuthProtectedResourcePrefix, + host: "api.example.com", + method: http.MethodOptions, + expectedStatusCode: http.StatusNoContent, + }, + { + name: "path with /mcp suffix", + cfg: &Config{ + BaseURL: "https://api.example.com", + }, + path: OAuthProtectedResourcePrefix + "/mcp", + host: "api.example.com", + method: http.MethodGet, + expectedStatusCode: http.StatusOK, + validateResponse: func(t *testing.T, body map[string]any) { + t.Helper() + assert.Equal(t, "https://api.example.com/mcp", body["resource"]) + }, + }, + { + name: "path with /readonly suffix", + cfg: &Config{ + BaseURL: "https://api.example.com", + }, + path: OAuthProtectedResourcePrefix + "/readonly", + host: "api.example.com", + method: http.MethodGet, + expectedStatusCode: http.StatusOK, + validateResponse: func(t *testing.T, body map[string]any) { + t.Helper() + assert.Equal(t, "https://api.example.com/readonly", body["resource"]) + }, + }, + { + name: "path with trailing slash", + cfg: &Config{ + BaseURL: "https://api.example.com", + }, + path: OAuthProtectedResourcePrefix + "/mcp/", + host: "api.example.com", + method: http.MethodGet, + expectedStatusCode: http.StatusOK, + validateResponse: func(t *testing.T, body map[string]any) { + t.Helper() + assert.Equal(t, "https://api.example.com/mcp/", body["resource"]) + }, + }, + { + name: "custom authorization server in response", + cfg: &Config{ + BaseURL: "https://api.example.com", + AuthorizationServer: "https://custom.auth.example.com/oauth", + }, + path: OAuthProtectedResourcePrefix, + host: "api.example.com", + method: http.MethodGet, + expectedStatusCode: http.StatusOK, + validateResponse: func(t *testing.T, body map[string]any) { + t.Helper() + authServers, ok := body["authorization_servers"].([]any) + require.True(t, ok) + require.Len(t, authServers, 1) + assert.Equal(t, "https://custom.auth.example.com/oauth", authServers[0]) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + dotcomHost, err := utils.NewAPIHost("https://api.github.com") + require.NoError(t, err) + + handler, err := NewAuthHandler(tc.cfg, dotcomHost) + require.NoError(t, err) + + router := chi.NewRouter() + handler.RegisterRoutes(router) + + req := httptest.NewRequest(tc.method, tc.path, nil) + req.Host = tc.host + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + assert.Equal(t, tc.expectedStatusCode, rec.Code) + + // Check CORS headers + assert.Equal(t, "*", rec.Header().Get("Access-Control-Allow-Origin")) + assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), "GET") + assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), "OPTIONS") + + if tc.method == http.MethodGet && tc.validateResponse != nil { + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + var body map[string]any + err := json.Unmarshal(rec.Body.Bytes(), &body) + require.NoError(t, err) + + tc.validateResponse(t, body) + + // Verify scopes if expected + if tc.expectedScopes != nil { + scopes, ok := body["scopes_supported"].([]any) + require.True(t, ok) + assert.Len(t, scopes, len(tc.expectedScopes)) + } + } + }) + } +} + +func TestRegisterRoutes(t *testing.T) { + t.Parallel() + + dotcomHost, err := utils.NewAPIHost("https://api.github.com") + require.NoError(t, err) + + handler, err := NewAuthHandler(&Config{ + BaseURL: "https://api.example.com", + }, dotcomHost) + require.NoError(t, err) + + router := chi.NewRouter() + handler.RegisterRoutes(router) + + // List of expected routes that should be registered + expectedRoutes := []string{ + OAuthProtectedResourcePrefix, + OAuthProtectedResourcePrefix + "/", + OAuthProtectedResourcePrefix + "/mcp", + OAuthProtectedResourcePrefix + "/mcp/", + OAuthProtectedResourcePrefix + "/readonly", + OAuthProtectedResourcePrefix + "/readonly/", + OAuthProtectedResourcePrefix + "/mcp/readonly", + OAuthProtectedResourcePrefix + "/mcp/readonly/", + OAuthProtectedResourcePrefix + "/x/repos", + OAuthProtectedResourcePrefix + "/mcp/x/repos", + } + + for _, route := range expectedRoutes { + t.Run("route:"+route, func(t *testing.T) { + // Test GET + req := httptest.NewRequest(http.MethodGet, route, nil) + req.Host = "api.example.com" + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code, "GET %s should return 200", route) + + // Test OPTIONS (CORS preflight) + req = httptest.NewRequest(http.MethodOptions, route, nil) + req.Host = "api.example.com" + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + assert.Equal(t, http.StatusNoContent, rec.Code, "OPTIONS %s should return 204", route) + }) + } +} + +func TestSupportedScopes(t *testing.T) { + t.Parallel() + + // Verify all expected scopes are present + expectedScopes := []string{ + "repo", + "read:org", + "read:user", + "user:email", + "read:packages", + "write:packages", + "read:project", + "project", + "gist", + "notifications", + "workflow", + "codespace", + } + + assert.Equal(t, expectedScopes, SupportedScopes) +} + +func TestProtectedResourceResponseFormat(t *testing.T) { + t.Parallel() + + dotcomHost, err := utils.NewAPIHost("https://api.github.com") + require.NoError(t, err) + + handler, err := NewAuthHandler(&Config{ + BaseURL: "https://api.example.com", + }, dotcomHost) + require.NoError(t, err) + + router := chi.NewRouter() + handler.RegisterRoutes(router) + + req := httptest.NewRequest(http.MethodGet, OAuthProtectedResourcePrefix, nil) + req.Host = "api.example.com" + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + var response map[string]any + err = json.Unmarshal(rec.Body.Bytes(), &response) + require.NoError(t, err) + + // Verify all required RFC 9728 fields are present + assert.Contains(t, response, "resource") + assert.Contains(t, response, "authorization_servers") + assert.Contains(t, response, "bearer_methods_supported") + assert.Contains(t, response, "scopes_supported") + + // Verify resource name (optional but we include it) + assert.Contains(t, response, "resource_name") + assert.Equal(t, "GitHub MCP Server", response["resource_name"]) + + // Verify bearer_methods_supported contains "header" + bearerMethods, ok := response["bearer_methods_supported"].([]any) + require.True(t, ok) + assert.Contains(t, bearerMethods, "header") + + // Verify authorization_servers is an array with GitHub OAuth + authServers, ok := response["authorization_servers"].([]any) + require.True(t, ok) + assert.Len(t, authServers, 1) + assert.Equal(t, defaultAuthorizationServer, authServers[0]) +} + +func TestOAuthProtectedResourcePrefix(t *testing.T) { + t.Parallel() + + // RFC 9728 specifies this well-known path + assert.Equal(t, "/.well-known/oauth-protected-resource", OAuthProtectedResourcePrefix) +} + +func TestDefaultAuthorizationServer(t *testing.T) { + t.Parallel() + + assert.Equal(t, "https://github.com/login/oauth", defaultAuthorizationServer) +} + +func TestAPIHostResolver_AuthorizationServerURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + host string + oauthConfig *Config + expectedURL string + expectedError bool + expectedStatusCode int + errorContains string + }{ + { + name: "valid host returns authorization server URL", + host: "https://github.com", + expectedURL: "https://github.com/login/oauth", + expectedStatusCode: http.StatusOK, + }, + { + name: "invalid host returns error", + host: "://invalid-url", + expectedURL: "", + expectedError: true, + errorContains: "could not parse host as URL", + }, + { + name: "host without scheme returns error", + host: "github.com", + expectedURL: "", + expectedError: true, + errorContains: "host must have a scheme", + }, + { + name: "GHEC host returns correct authorization server URL", + host: "https://test.ghe.com", + expectedURL: "https://test.ghe.com/login/oauth", + expectedStatusCode: http.StatusOK, + }, + { + name: "GHES host returns correct authorization server URL", + host: "https://ghe.example.com", + expectedURL: "https://ghe.example.com/login/oauth", + expectedStatusCode: http.StatusOK, + }, + { + name: "GHES with http scheme returns the correct authorization server URL", + host: "http://ghe.example.com", + expectedURL: "http://ghe.example.com/login/oauth", + expectedStatusCode: http.StatusOK, + }, + { + name: "custom authorization server in config takes precedence", + host: "https://github.com", + oauthConfig: &Config{ + AuthorizationServer: "https://custom.auth.example.com/oauth", + }, + expectedURL: "https://custom.auth.example.com/oauth", + expectedStatusCode: http.StatusOK, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + apiHost, err := utils.NewAPIHost(tc.host) + if tc.expectedError { + require.Error(t, err) + if tc.errorContains != "" { + assert.Contains(t, err.Error(), tc.errorContains) + } + return + } + require.NoError(t, err) + + config := tc.oauthConfig + if config == nil { + config = &Config{} + } + config.BaseURL = tc.host + + handler, err := NewAuthHandler(config, apiHost) + require.NoError(t, err) + + router := chi.NewRouter() + handler.RegisterRoutes(router) + + req := httptest.NewRequest(http.MethodGet, OAuthProtectedResourcePrefix, nil) + req.Host = "api.example.com" + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + var response map[string]any + err = json.Unmarshal(rec.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Contains(t, response, "authorization_servers") + if tc.expectedStatusCode != http.StatusOK { + require.Equal(t, tc.expectedStatusCode, rec.Code) + if tc.errorContains != "" { + assert.Contains(t, rec.Body.String(), tc.errorContains) + } + return + } + + responseAuthServers, ok := response["authorization_servers"].([]any) + require.True(t, ok) + require.Len(t, responseAuthServers, 1) + assert.Equal(t, tc.expectedURL, responseAuthServers[0]) + }) + } +} diff --git a/pkg/http/server.go b/pkg/http/server.go new file mode 100644 index 0000000000..36d3e111bc --- /dev/null +++ b/pkg/http/server.go @@ -0,0 +1,270 @@ +package http + +import ( + "context" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "strconv" + "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" +) + +type ServerConfig struct { + // Version of the server + Version string + + // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) + Host string + + // Port to listen on (default: 8082). + Port int + + // ListenHost is the host the HTTP server binds to (e.g. "127.0.0.1"). + // When empty, the server binds to all interfaces. Combined with Port. + ListenHost string + + // BaseURL is the publicly accessible URL of this server for OAuth resource metadata. + // If not set, the server will derive the URL from incoming request headers. + BaseURL string + + // ResourcePath is the externally visible base path for this server (e.g., "/mcp"). + // 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 + + // EnableCommandLogging indicates if we should log commands + EnableCommandLogging bool + + // Path to the log file if not stderr + LogFilePath string + + // Content window size + ContentWindowSize int + + // LockdownMode indicates if we should enable lockdown mode + LockdownMode bool + + // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. + RepoAccessCacheTTL *time.Duration + + // 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 { + // Create app context + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + t, dumpTranslations := translations.TranslationHelper() + + var slogHandler slog.Handler + var logOutput io.Writer + if cfg.LogFilePath != "" { + file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return fmt.Errorf("failed to open log file: %w", err) + } + logOutput = file + slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug}) + } else { + logOutput = os.Stderr + 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, "readOnly", cfg.ReadOnly, "insidersMode", cfg.InsidersMode) + + apiHost, err := utils.NewAPIHost(cfg.Host) + if err != nil { + return fmt.Errorf("failed to parse API host: %w", err) + } + + repoAccessOpts := []lockdown.RepoAccessOption{ + lockdown.WithLogger(logger.With("component", "lockdown")), + } + if cfg.RepoAccessCacheTTL != nil { + repoAccessOpts = append(repoAccessOpts, lockdown.WithTTL(*cfg.RepoAccessCacheTTL)) + } + + 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, + cfg.Version, + cfg.LockdownMode, + repoAccessOpts, + t, + cfg.ContentWindowSize, + featureChecker, + obs, + ) + + // Initialize the global tool scope map + err = initGlobalToolScopeMap(t) + if err != nil { + return fmt.Errorf("failed to initialize tool scope map: %w", err) + } + + // Register OAuth protected resource metadata endpoints + oauthCfg := &oauth.Config{ + BaseURL: cfg.BaseURL, + ResourcePath: cfg.ResourcePath, + TrustProxyHeaders: cfg.TrustProxyHeaders, + } + + serverOptions := []HandlerOption{} + if cfg.ScopeChallenge { + scopeFetcher := scopes.NewFetcher(apiHost, scopes.FetcherOptions{}) + serverOptions = append(serverOptions, WithScopeFetcher(scopeFetcher)) + } + + r := chi.NewRouter() + handler := NewHTTPMcpHandler(ctx, &cfg, deps, t, logger, apiHost, append(serverOptions, WithFeatureChecker(featureChecker), WithOAuthConfig(oauthCfg))...) + oauthHandler, err := oauth.NewAuthHandler(oauthCfg, apiHost) + if err != nil { + return fmt.Errorf("failed to create OAuth handler: %w", err) + } + + r.Group(func(r chi.Router) { + r.Use(middleware.SetCorsHeaders) + + // Register Middleware First, needs to be before route registration + handler.RegisterMiddleware(r) + + // Register MCP server routes + handler.RegisterRoutes(r) + }) + logger.Info("MCP endpoints registered", "baseURL", cfg.BaseURL) + + r.Group(func(r chi.Router) { + // Register OAuth protected resource metadata endpoints + oauthHandler.RegisterRoutes(r) + }) + logger.Info("OAuth protected resource endpoints registered", "baseURL", cfg.BaseURL) + + addr := resolveListenAddress(cfg.ListenHost, cfg.Port) + httpSvr := http.Server{ + Addr: addr, + Handler: r, + ReadHeaderTimeout: 60 * time.Second, + } + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + logger.Info("shutting down server") + if err := httpSvr.Shutdown(shutdownCtx); err != nil { + logger.Error("error during server shutdown", "error", err) + } + }() + + if cfg.ExportTranslations { + // Once server is initialized, all translations are loaded + dumpTranslations() + } + + logger.Info("HTTP server listening", "addr", addr) + if err := httpSvr.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("HTTP server error: %w", err) + } + + logger.Info("server stopped gracefully") + return nil +} + +// resolveListenAddress returns the address string passed to http.Server. +// When host is empty the server binds to all interfaces on the given port; +// otherwise host and port are joined into a single address. +func resolveListenAddress(host string, port int) string { + if host == "" { + return fmt.Sprintf(":%d", port) + } + return net.JoinHostPort(host, strconv.Itoa(port)) +} + +func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error { + // Build inventory with all tools to extract scope information + inv, err := inventory.NewBuilder(). + SetTools(github.AllTools(t)). + Build() + + if err != nil { + return fmt.Errorf("failed to build inventory for tool scope map: %w", err) + } + + // Initialize the global scope map + scopes.SetToolScopeMapFromInventory(inv) + + return nil +} + +// 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) { + 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..b509876d9e --- /dev/null +++ b/pkg/http/server_test.go @@ -0,0 +1,175 @@ +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 does not auto-enable ifc labels", + flagName: github.FeatureFlagIFCLabels, + insidersMode: true, + wantEnabled: false, + }, + { + 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 TestResolveListenAddress(t *testing.T) { + tests := []struct { + name string + host string + port int + want string + }{ + { + name: "empty host falls back to :port", + host: "", + port: 8082, + want: ":8082", + }, + { + name: "ipv4 host is joined with port", + host: "127.0.0.1", + port: 9090, + want: "127.0.0.1:9090", + }, + { + name: "ipv6 host is bracketed and joined with port", + host: "::1", + port: 9090, + want: "[::1]:9090", + }, + { + name: "hostname is joined with port", + host: "localhost", + port: 8082, + want: "localhost:8082", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveListenAddress(tt.host, tt.port) + assert.Equal(t, tt.want, got) + }) + } +} + +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/http/transport/bearer.go b/pkg/http/transport/bearer.go new file mode 100644 index 0000000000..66922bbdaa --- /dev/null +++ b/pkg/http/transport/bearer.go @@ -0,0 +1,26 @@ +package transport + +import ( + "net/http" + "strings" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + headers "github.com/github/github-mcp-server/pkg/http/headers" +) + +type BearerAuthTransport struct { + Transport http.RoundTripper + Token string +} + +func (t *BearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set(headers.AuthorizationHeader, "Bearer "+t.Token) + + // Check for GraphQL-Features in context and add header if present + if features := ghcontext.GetGraphQLFeatures(req.Context()); len(features) > 0 { + req.Header.Set(headers.GraphQLFeaturesHeader, strings.Join(features, ", ")) + } + + return t.Transport.RoundTrip(req) +} diff --git a/pkg/github/transport.go b/pkg/http/transport/graphql_features.go similarity index 69% rename from pkg/github/transport.go rename to pkg/http/transport/graphql_features.go index 0a4372b235..7fe9182fcb 100644 --- a/pkg/github/transport.go +++ b/pkg/http/transport/graphql_features.go @@ -1,8 +1,11 @@ -package github +package transport import ( "net/http" "strings" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/http/headers" ) // GraphQLFeaturesTransport is an http.RoundTripper that adds GraphQL-Features @@ -15,14 +18,16 @@ import ( // // Usage: // +// import "github.com/github/github-mcp-server/pkg/http/transport" +// // httpClient := &http.Client{ -// Transport: &github.GraphQLFeaturesTransport{ +// Transport: &transport.GraphQLFeaturesTransport{ // Transport: http.DefaultTransport, // }, // } // gqlClient := githubv4.NewClient(httpClient) // -// Then use withGraphQLFeatures(ctx, "feature_name") when calling GraphQL operations. +// Then use ghcontext.WithGraphQLFeatures(ctx, "feature_name") when calling GraphQL operations. type GraphQLFeaturesTransport struct { // Transport is the underlying HTTP transport. If nil, http.DefaultTransport is used. Transport http.RoundTripper @@ -39,8 +44,8 @@ func (t *GraphQLFeaturesTransport) RoundTrip(req *http.Request) (*http.Response, req = req.Clone(req.Context()) // Check for GraphQL-Features in context and add header if present - if features := GetGraphQLFeatures(req.Context()); len(features) > 0 { - req.Header.Set("GraphQL-Features", strings.Join(features, ", ")) + if features := ghcontext.GetGraphQLFeatures(req.Context()); len(features) > 0 { + req.Header.Set(headers.GraphQLFeaturesHeader, strings.Join(features, ", ")) } return transport.RoundTrip(req) diff --git a/pkg/github/transport_test.go b/pkg/http/transport/graphql_features_test.go similarity index 83% rename from pkg/github/transport_test.go rename to pkg/http/transport/graphql_features_test.go index c981082555..1a0dc4214f 100644 --- a/pkg/github/transport_test.go +++ b/pkg/http/transport/graphql_features_test.go @@ -1,4 +1,4 @@ -package github +package transport import ( "context" @@ -6,6 +6,9 @@ import ( "net/http/httptest" "testing" + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -54,8 +57,8 @@ func TestGraphQLFeaturesTransport(t *testing.T) { // Create a test server that captures the request header server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedHeader = r.Header.Get("GraphQL-Features") - headerExists = r.Header.Get("GraphQL-Features") != "" + capturedHeader = r.Header.Get(headers.GraphQLFeaturesHeader) + headerExists = r.Header.Get(headers.GraphQLFeaturesHeader) != "" w.WriteHeader(http.StatusOK) })) defer server.Close() @@ -68,7 +71,7 @@ func TestGraphQLFeaturesTransport(t *testing.T) { // Create a request ctx := context.Background() if tc.features != nil { - ctx = withGraphQLFeatures(ctx, tc.features...) + ctx = ghcontext.WithGraphQLFeatures(ctx, tc.features...) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil) @@ -95,7 +98,7 @@ func TestGraphQLFeaturesTransport_NilTransport(t *testing.T) { // Create a test server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedHeader = r.Header.Get("GraphQL-Features") + capturedHeader = r.Header.Get(headers.GraphQLFeaturesHeader) w.WriteHeader(http.StatusOK) })) defer server.Close() @@ -106,7 +109,7 @@ func TestGraphQLFeaturesTransport_NilTransport(t *testing.T) { } // Create a request with features - ctx := withGraphQLFeatures(context.Background(), "test_feature") + ctx := ghcontext.WithGraphQLFeatures(context.Background(), "test_feature") req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil) require.NoError(t, err) @@ -134,12 +137,12 @@ func TestGraphQLFeaturesTransport_DoesNotMutateOriginalRequest(t *testing.T) { } // Create a request with features - ctx := withGraphQLFeatures(context.Background(), "test_feature") + ctx := ghcontext.WithGraphQLFeatures(context.Background(), "test_feature") req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil) require.NoError(t, err) // Store the original header value - originalHeader := req.Header.Get("GraphQL-Features") + originalHeader := req.Header.Get(headers.GraphQLFeaturesHeader) // Execute the request resp, err := transport.RoundTrip(req) @@ -147,5 +150,5 @@ func TestGraphQLFeaturesTransport_DoesNotMutateOriginalRequest(t *testing.T) { defer resp.Body.Close() // Verify the original request was not mutated - assert.Equal(t, originalHeader, req.Header.Get("GraphQL-Features")) + assert.Equal(t, originalHeader, req.Header.Get(headers.GraphQLFeaturesHeader)) } diff --git a/pkg/http/transport/user_agent.go b/pkg/http/transport/user_agent.go new file mode 100644 index 0000000000..a489941cce --- /dev/null +++ b/pkg/http/transport/user_agent.go @@ -0,0 +1,18 @@ +package transport + +import ( + "net/http" + + "github.com/github/github-mcp-server/pkg/http/headers" +) + +type UserAgentTransport struct { + Transport http.RoundTripper + Agent string +} + +func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set(headers.UserAgentHeader, t.Agent) + return t.Transport.RoundTrip(req) +} diff --git a/pkg/ifc/ifc.go b/pkg/ifc/ifc.go new file mode 100644 index 0000000000..f23383ce73 --- /dev/null +++ b/pkg/ifc/ifc.go @@ -0,0 +1,383 @@ +// 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, + } +} + +// LabelGetMe returns the IFC label for the authenticated user's own profile +// (get_me). +// +// Integrity is trusted: this is GitHub-maintained data about the caller's own +// account, not attacker-authored content. +// +// Confidentiality is private. The result includes fields that are NOT part of +// the user's public profile — private_gists, total_private_repos, and +// owned_private_repos — which are visible only to the authenticated user. The +// result therefore must not be treated as world-readable. +func LabelGetMe() SecurityLabel { + return PrivateTrusted() +} + +// 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). +// Public repository issue contents are attacker-controllable, while private +// repository issues are treated as trusted collaborator-authored data. +func LabelListIssues(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateTrusted() + } + return PublicUntrusted() +} + +// LabelRepoUserContent returns the IFC label for user-authored content scoped +// to a repository when that tool has not opted into a more specific integrity +// policy. Public repository content is untrusted because it may be authored by +// outside contributors. Private repository content is trusted because users who +// can read it are trusted collaborators. +func LabelRepoUserContent(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateTrusted() + } + 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. +// +// Public-only results are untrusted and public. All-private results are trusted +// and private because private repository content is treated as trusted +// collaborator-authored data. Mixed public/private results are untrusted and +// private: the public items keep the joined payload's integrity untrusted, +// while the private items keep the joined payload's confidentiality private. +// 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). +// +// Why a single joined label rather than one label per item: a tool result is +// delivered as one opaque payload (a single content block) and the IFC engine +// makes one allow/deny decision per flow at egress. Once the items share a +// buffer in the agent's context they can be copied anywhere together, so the +// only sound bound for the whole result is the meet of every item's label. +// Per-item labels would only become load-bearing if the enforcement engine +// could partition a result and route individual items to different sinks; +// until then they would invite unsafe declassification of a "public" item that +// actually arrived alongside private data. +func LabelSearchIssues(repoVisibilities []bool) SecurityLabel { + var anyPrivate, anyPublic bool + for _, isPrivate := range repoVisibilities { + if isPrivate { + anyPrivate = true + } else { + anyPublic = true + } + } + switch { + case anyPrivate && anyPublic: + return PrivateUntrusted() + case anyPrivate: + return PrivateTrusted() + default: + return PublicUntrusted() + } +} + +// LabelRepoMetadata returns the IFC label for structural repository metadata +// that only collaborators with write access can define: labels, branches, +// tags, releases, issue types, issue field definitions, discussion +// categories, and the collaborator roster. +// +// Integrity is trusted because, unlike issue/PR/comment bodies, these +// artifacts cannot be authored by arbitrary outsiders — creating a branch, +// tag, release, or label requires push access, so the data reflects decisions +// made by the repository's trusted writers rather than attacker-controllable +// input. +// +// Confidentiality follows repository visibility: public repositories are +// universally readable; private repositories restrict the reader set (the +// opaque "private" marker, resolved client-side at egress time). +func LabelRepoMetadata(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateTrusted() + } + return PublicTrusted() +} + +// LabelRelease returns the IFC label for repository releases (list_releases, +// get_latest_release, get_release_by_tag). +// +// Integrity is trusted: releases are published by collaborators with push +// access, not by arbitrary outsiders. +// +// Confidentiality is public only when the repository is public AND no returned +// release is a draft. Draft releases are visible only to users with push +// access — they are NOT world-readable even on a public repository — so a +// result containing one must be private. hasDraft reflects whether any release +// in the result is a draft; private repositories are always private regardless. +func LabelRelease(isPrivate bool, hasDraft bool) SecurityLabel { + if isPrivate || hasDraft { + return PrivateTrusted() + } + return PublicTrusted() +} + +// LabelCollaboratorRoster returns the IFC label for a repository's collaborator +// list (list_repository_collaborators). +// +// Integrity is trusted: the roster is GitHub-maintained membership data, not +// attacker-authored content. +// +// Confidentiality is always private. Listing collaborators requires push +// access to the repository, so the roster is never world-readable — not even +// for public repositories. This mirrors LabelTeam: membership data is +// restricted regardless of the repository's own visibility. +func LabelCollaboratorRoster() SecurityLabel { + return PrivateTrusted() +} + +// LabelCommitContents returns the IFC label for committed repository content +// reachable from the default branch and its history: commits, commit diffs, +// and the repository file tree. +// +// It shares the reasoning of LabelGetFileContents. In public repositories any +// outsider can land content via a pull request, so the integrity of committed +// content is untrusted. In private repositories only collaborators can push, +// so committed content is trusted. Confidentiality follows repository +// visibility. +func LabelCommitContents(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateTrusted() + } + return PublicUntrusted() +} + +// LabelActionsResult returns the IFC label for GitHub Actions resources: +// workflow definitions, runs, jobs, artifacts, and job logs. +// +// Integrity is untrusted. Workflow logs echo arbitrary text produced during a +// run — including output derived from pull-request branches, dependency +// downloads, and other attacker-influenceable sources — so log and artifact +// content must be treated as low integrity. Workflow definitions are +// themselves editable through pull requests in public repositories. +// +// Confidentiality follows repository visibility. +func LabelActionsResult(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateUntrusted() + } + return PublicUntrusted() +} + +// LabelSecurityAlert returns the IFC label for security findings: code +// scanning alerts, secret scanning alerts, and Dependabot alerts. +// +// Integrity is untrusted because alert payloads embed attacker-influenceable +// material — the offending code snippet, the matched secret string, or a +// vulnerable dependency's advisory text — none of which the agent should treat +// as a trustworthy instruction source. +// +// Confidentiality is always private. Security alerts are access-restricted by +// GitHub regardless of repository visibility (only users with a security role +// can read them), so the reader set is narrow even for public repositories. +// Secret scanning results additionally surface the secret material itself. +func LabelSecurityAlert() SecurityLabel { + return PrivateUntrusted() +} + +// LabelGlobalSecurityAdvisory returns the IFC label for advisories served from +// the public GitHub Advisory Database (global advisories). +// +// The advisory database is world-readable, so confidentiality is public. +// Integrity is untrusted: advisory descriptions are externally authored prose +// and must not be treated as a trusted instruction source. +func LabelGlobalSecurityAdvisory() SecurityLabel { + return PublicUntrusted() +} + +// LabelRepositorySecurityAdvisory returns the IFC label for repository- or +// organization-scoped security advisories. +// +// Integrity is untrusted (externally authored advisory prose). +// +// Confidentiality is public only when the repository is public AND every +// advisory in the result is in the "published" state. Repository security +// advisories also exist in draft, triage, and closed states; those are visible +// only to maintainers and are NOT world-readable even on a public repository. +// Treating any non-published advisory as private (allPublished == false) +// prevents misclassifying an unpublished advisory from a public repo as +// public-readable. Private repositories are always private regardless of state. +func LabelRepositorySecurityAdvisory(isPrivate bool, allPublished bool) SecurityLabel { + if isPrivate || !allPublished { + return PrivateUntrusted() + } + return PublicUntrusted() +} + +// LabelGist returns the IFC label for gist content. +// +// Integrity is untrusted: gist contents are arbitrary user-authored text. +// Confidentiality is public because secret gists are URL-accessible and cannot +// be modeled as private to a GitHub reader set. +func LabelGist() SecurityLabel { + return PublicUntrusted() +} + +// LabelGistList returns the IFC label for a list of gists belonging to a user, +// joining the per-gist confidentiality across the result set. +// +// Integrity is untrusted (user-authored content). Confidentiality is public +// because even secret gists are URL-accessible. +// +// See LabelSearchIssues for why list results carry a single joined label +// rather than one label per item. +func LabelGistList() SecurityLabel { + return PublicUntrusted() +} + +// LabelProject returns the IFC label for GitHub Project metadata (Projects v2), +// such as get_project results and project field definitions. +// +// Public project metadata can contain public user-authored text, so it is +// untrusted. Private project metadata is treated as trusted +// collaborator-controlled data. +// +// Confidentiality derives from the project's own privacy — private projects +// restrict the reader set, while public projects are universally readable. +func LabelProject(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateTrusted() + } + return PublicUntrusted() +} + +// LabelProjectList returns the IFC label for a list_projects result, joining +// the per-project labels across every returned project. +// +// Public-only results are untrusted and public. All-private results are trusted +// and private. Mixed public/private results are untrusted and private: public +// items keep the joined payload's integrity untrusted, while private items keep +// the joined payload's confidentiality private. +func LabelProjectList(projectVisibilities []bool) SecurityLabel { + var anyPrivate, anyPublic bool + for _, isPrivate := range projectVisibilities { + if isPrivate { + anyPrivate = true + } else { + anyPublic = true + } + } + switch { + case anyPrivate && anyPublic: + return PrivateUntrusted() + case anyPrivate: + return PrivateTrusted() + default: + return PublicUntrusted() + } +} + +// LabelProjectContent returns the IFC label for project results that can include +// item content, field values, or status update bodies. These can aggregate +// content from a variety of sources, so integrity remains untrusted even when +// the project is private. +func LabelProjectContent(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateUntrusted() + } + return PublicUntrusted() +} + +// LabelTeam returns the IFC label for organization team membership data +// (get_teams, get_team_members). +// +// Integrity is trusted: team membership is maintained by GitHub and cannot be +// forged by outside contributors, so it is not an attacker-controllable +// instruction source. +// +// Confidentiality is private. Organization team rosters and the teams a user +// belongs to are visible only to members of the organization, not to the +// public, so the reader set is restricted (the opaque "private" marker). +func LabelTeam() SecurityLabel { + return PrivateTrusted() +} + +// LabelNotificationDetails returns the IFC label for the subject of a single +// notification. +// +// Integrity is untrusted: a notification subject points at an issue, pull +// request, comment, or discussion whose content is user-authored and may carry +// attacker-controlled text. Confidentiality is private because notifications +// are delivered to a specific recipient and may reference private +// repositories; the result cannot be assumed to be publicly readable. +func LabelNotificationDetails() SecurityLabel { + return PrivateUntrusted() +} diff --git a/pkg/ifc/ifc_test.go b/pkg/ifc/ifc_test.go new file mode 100644 index 0000000000..f4b25c1876 --- /dev/null +++ b/pkg/ifc/ifc_test.go @@ -0,0 +1,366 @@ +package ifc + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLabelListIssues(t *testing.T) { + t.Parallel() + + t.Run("public repo issues are untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelListIssues(false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private repo issues are trusted and private", func(t *testing.T) { + t.Parallel() + label := LabelListIssues(true) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelRepoUserContent(t *testing.T) { + t.Parallel() + + t.Run("public repo user content is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelRepoUserContent(false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private repo user content is trusted and private", func(t *testing.T) { + t.Parallel() + label := LabelRepoUserContent(true) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelSearchIssues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + visibilities []bool // true == private + wantIntegrity Integrity + wantConfidential Confidentiality + }{ + { + name: "empty result is treated as public", + wantIntegrity: IntegrityUntrusted, + wantConfidential: ConfidentialityPublic, + }, + { + name: "single public repo", + visibilities: []bool{false}, + wantIntegrity: IntegrityUntrusted, + wantConfidential: ConfidentialityPublic, + }, + { + name: "all public repos stay public", + visibilities: []bool{false, false, false}, + wantIntegrity: IntegrityUntrusted, + wantConfidential: ConfidentialityPublic, + }, + { + name: "mixed public and private repos become untrusted private", + visibilities: []bool{false, true, false}, + wantIntegrity: IntegrityUntrusted, + wantConfidential: ConfidentialityPrivate, + }, + { + name: "all private repos stay trusted private", + visibilities: []bool{true, true}, + wantIntegrity: IntegrityTrusted, + wantConfidential: ConfidentialityPrivate, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + label := LabelSearchIssues(tc.visibilities) + assert.Equal(t, tc.wantIntegrity, label.Integrity) + assert.Equal(t, tc.wantConfidential, label.Confidentiality) + }) + } +} + +func TestLabelRepoMetadata(t *testing.T) { + t.Parallel() + + t.Run("public repo metadata is trusted and public", func(t *testing.T) { + t.Parallel() + label := LabelRepoMetadata(false) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private repo metadata is trusted and private", func(t *testing.T) { + t.Parallel() + label := LabelRepoMetadata(true) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelGetMe(t *testing.T) { + t.Parallel() + + // get_me exposes private_gists/total_private_repos/owned_private_repos, + // which are not part of the public profile, so the result is trusted but + // private — never public. + label := LabelGetMe() + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) +} + +func TestLabelRelease(t *testing.T) { + t.Parallel() + + t.Run("public repo with no draft is trusted and public", func(t *testing.T) { + t.Parallel() + label := LabelRelease(false, false) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("public repo with a draft release is private", func(t *testing.T) { + t.Parallel() + // Draft releases are visible only to push-access users, so a draft on + // a public repo must not be labeled public. + label := LabelRelease(false, true) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) + + t.Run("private repo is private regardless of draft", func(t *testing.T) { + t.Parallel() + for _, hasDraft := range []bool{false, true} { + label := LabelRelease(true, hasDraft) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + } + }) +} + +func TestLabelCollaboratorRoster(t *testing.T) { + t.Parallel() + + // A collaborator roster requires push access to list, so it is never + // world-readable — always trusted and private. + label := LabelCollaboratorRoster() + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) +} + +func TestLabelCommitContents(t *testing.T) { + t.Parallel() + + t.Run("public repo commit content is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelCommitContents(false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private repo commit content is trusted and private", func(t *testing.T) { + t.Parallel() + label := LabelCommitContents(true) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelActionsResult(t *testing.T) { + t.Parallel() + + t.Run("public repo actions result is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelActionsResult(false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private repo actions result is untrusted and private", func(t *testing.T) { + t.Parallel() + label := LabelActionsResult(true) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelSecurityAlert(t *testing.T) { + t.Parallel() + label := LabelSecurityAlert() + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality, + "security alerts are access-restricted regardless of repo visibility") +} + +func TestLabelGlobalSecurityAdvisory(t *testing.T) { + t.Parallel() + label := LabelGlobalSecurityAdvisory() + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) +} + +func TestLabelRepositorySecurityAdvisory(t *testing.T) { + t.Parallel() + + t.Run("public repo with all published advisories is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelRepositorySecurityAdvisory(false, true) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("public repo with an unpublished advisory is untrusted and private", func(t *testing.T) { + t.Parallel() + // draft/triage/closed advisories are not world-readable even on a + // public repo, so confidentiality must be private. + label := LabelRepositorySecurityAdvisory(false, false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) + + t.Run("private repo advisory is untrusted and private", func(t *testing.T) { + t.Parallel() + label := LabelRepositorySecurityAdvisory(true, true) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) + + t.Run("private repo with unpublished advisory is untrusted and private", func(t *testing.T) { + t.Parallel() + label := LabelRepositorySecurityAdvisory(true, false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelGist(t *testing.T) { + t.Parallel() + + t.Run("public gist is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelGist() + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("secret gist is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelGist() + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) +} + +func TestLabelGistList(t *testing.T) { + t.Parallel() + + label := LabelGistList() + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) +} + +func TestLabelProject(t *testing.T) { + t.Parallel() + + t.Run("public project is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelProject(false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private project metadata is trusted and private", func(t *testing.T) { + t.Parallel() + label := LabelProject(true) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelProjectList(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + visibilities []bool // true == private + wantIntegrity Integrity + wantConfidential Confidentiality + }{ + { + name: "empty result is treated as public", + wantIntegrity: IntegrityUntrusted, + wantConfidential: ConfidentialityPublic, + }, + { + name: "all public projects stay public", + visibilities: []bool{false, false}, + wantIntegrity: IntegrityUntrusted, + wantConfidential: ConfidentialityPublic, + }, + { + name: "mixed public and private projects become untrusted private", + visibilities: []bool{false, true}, + wantIntegrity: IntegrityUntrusted, + wantConfidential: ConfidentialityPrivate, + }, + { + name: "all private projects stay trusted private", + visibilities: []bool{true, true}, + wantIntegrity: IntegrityTrusted, + wantConfidential: ConfidentialityPrivate, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + label := LabelProjectList(tc.visibilities) + assert.Equal(t, tc.wantIntegrity, label.Integrity) + assert.Equal(t, tc.wantConfidential, label.Confidentiality) + }) + } +} + +func TestLabelProjectContent(t *testing.T) { + t.Parallel() + + t.Run("public project content is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelProjectContent(false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private project content is untrusted and private", func(t *testing.T) { + t.Parallel() + label := LabelProjectContent(true) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelTeam(t *testing.T) { + t.Parallel() + label := LabelTeam() + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) +} + +func TestLabelNotificationDetails(t *testing.T) { + t.Parallel() + label := LabelNotificationDetails() + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) +} diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index ff2d06d5da..c8a9c21bc9 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -2,11 +2,25 @@ package inventory import ( "context" + "errors" "fmt" - "sort" + "maps" + "slices" "strings" + + "github.com/google/jsonschema-go/jsonschema" +) + +var ( + // ErrUnknownTools is returned when tools specified via WithTools() are not recognized. + 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) @@ -72,9 +86,7 @@ func (b *Builder) SetPrompts(prompts []ServerPrompt) *Builder { // WithDeprecatedAliases adds deprecated tool name aliases that map to canonical names. // Returns self for chaining. func (b *Builder) WithDeprecatedAliases(aliases map[string]string) *Builder { - for oldName, newName := range aliases { - b.deprecatedAliases[oldName] = newName - } + maps.Copy(b.deprecatedAliases, aliases) return b } @@ -96,8 +108,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 @@ -118,8 +129,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 @@ -135,6 +158,33 @@ func (b *Builder) WithFilter(filter ToolFilter) *Builder { return b } +// WithExcludeTools specifies tools that should be disabled regardless of other settings. +// These tools will be excluded even if their toolset is enabled or they are in the +// additional tools list. This takes precedence over all other tool enablement settings. +// Input is cleaned (trimmed, deduplicated) before applying. +// Returns self for chaining. +func (b *Builder) WithExcludeTools(toolNames []string) *Builder { + cleaned := cleanTools(toolNames) + if len(cleaned) > 0 { + b.filters = append(b.filters, CreateExcludeToolsFilter(cleaned)) + } + 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). +func CreateExcludeToolsFilter(excluded []string) ToolFilter { + set := make(map[string]struct{}, len(excluded)) + for _, name := range excluded { + set[name] = struct{}{} + } + return func(_ context.Context, tool *ServerTool) (bool, error) { + _, blocked := set[tool.Tool.Name] + return !blocked, nil + } +} + // cleanTools trims whitespace and removes duplicates from tool names. // Empty strings after trimming are excluded. func cleanTools(tools []string) []string { @@ -162,23 +212,35 @@ 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) { + tools := 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{ - tools: b.tools, + tools: tools, resourceTemplates: b.resourceTemplates, prompts: b.prompts, deprecatedAliases: b.deprecatedAliases, readOnly: b.readOnly, featureChecker: b.featureChecker, - filters: b.filters, + filters: filters, } // Process toolsets and pre-compute metadata in a single pass r.enabledToolsets, r.unrecognizedToolsets, r.toolsetIDs, r.toolsetIDSet, r.defaultToolsetIDs, r.toolsetDescriptions = b.processToolsets() // Build set of valid tool names for validation - validToolNames := make(map[string]bool, len(b.tools)) - for i := range b.tools { - validToolNames[b.tools[i].Tool.Name] = true + validToolNames := make(map[string]bool, len(tools)) + for i := range tools { + validToolNames[tools[i].Tool.Name] = true } // Process additional tools (clean, resolve aliases, and track unrecognized) @@ -204,7 +266,7 @@ func (b *Builder) Build() (*Inventory, error) { // Error out if there are unrecognized tools if len(unrecognizedTools) > 0 { - return nil, fmt.Errorf("unrecognized tools: %s", strings.Join(unrecognizedTools, ", ")) + return nil, fmt.Errorf("%w: %s", ErrUnknownTools, strings.Join(unrecognizedTools, ", ")) } } @@ -264,13 +326,13 @@ func (b *Builder) processToolsets() (map[ToolsetID]bool, []string, []ToolsetID, for id := range validIDs { allToolsetIDs = append(allToolsetIDs, id) } - sort.Slice(allToolsetIDs, func(i, j int) bool { return allToolsetIDs[i] < allToolsetIDs[j] }) + slices.Sort(allToolsetIDs) defaultToolsetIDList := make([]ToolsetID, 0, len(defaultIDs)) for id := range defaultIDs { defaultToolsetIDList = append(defaultToolsetIDList, id) } - sort.Slice(defaultToolsetIDList, func(i, j int) bool { return defaultToolsetIDList[i] < defaultToolsetIDList[j] }) + slices.Sort(defaultToolsetIDList) toolsetIDs := b.toolsetIDs @@ -326,3 +388,149 @@ func (b *Builder) processToolsets() (map[ToolsetID]bool, []string, []ToolsetID, } return enabledToolsets, unrecognized, allToolsetIDs, validIDs, defaultToolsetIDList, descriptions } + +// mcpAppsMetaKeys lists the Meta keys controlled by the remote_mcp_ui_apps feature flag. +var mcpAppsMetaKeys = []string{ + "ui", // MCP Apps UI metadata +} + +// 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 { + if stripped := stripMetaKeys(tool, mcpAppsMetaKeys); stripped != nil { + result = append(result, *stripped) + } else { + result = append(result, tool) + } + } + return result +} + +// uiOnlySchemaProperties lists input-schema property names that should only +// be visible to clients that advertise MCP Apps UI support. They live on the +// static schema (so toolsnaps and the feature-flag / insiders docs document +// the full UI-capable surface; the main README renders the stripped +// non-UI schema) and are stripped per-request when the same gate that hides +// _meta.ui is true. +var uiOnlySchemaProperties = []string{ + "show_ui", // explicit "render the MCP App form" toggle on form-backed write tools +} + +// ConditionalSchemaPropertyDescriptions returns a map of schema property name +// to a human-readable description of the condition under which the property +// is visible to clients. The doc generator uses this to annotate conditional +// parameters so readers can see at a glance which fields are not always +// available. This is the single source of truth for the conditional-property +// surface — entries here must correspond to a strip rule in +// ToolsForRegistration. +func ConditionalSchemaPropertyDescriptions() map[string]string { + const uiOnlyCondition = "visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui" + out := make(map[string]string, len(uiOnlySchemaProperties)) + for _, name := range uiOnlySchemaProperties { + out[name] = uiOnlyCondition + } + return out +} + +// stripUIOnlySchemaProperties removes UI-capability-gated input-schema +// properties (currently just "show_ui") from each tool's static schema. +// Tools whose InputSchema is not a *jsonschema.Schema (e.g. json.RawMessage) +// are passed through untouched — no such tool currently declares a gated +// property, and inferring intent from an opaque schema is not safe. +// Tools without any gated property are returned as-is so we only allocate +// when a change is actually made (mirrors the stripMetaKeys pattern). +func stripUIOnlySchemaProperties(tools []ServerTool) []ServerTool { + result := make([]ServerTool, 0, len(tools)) + for _, tool := range tools { + if stripped := stripSchemaProperties(tool, uiOnlySchemaProperties); stripped != nil { + result = append(result, *stripped) + } else { + result = append(result, tool) + } + } + return result +} + +// stripSchemaProperties removes the named keys from tool.Tool.InputSchema's +// Properties map (and Required list, if present) and returns a modified copy. +// Returns nil when the schema is not a *jsonschema.Schema or no listed key +// is present, signalling no change. +func stripSchemaProperties(tool ServerTool, keys []string) *ServerTool { + if tool.Tool.InputSchema == nil || len(keys) == 0 { + return nil + } + schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema) + if !ok || schema == nil || len(schema.Properties) == 0 { + return nil + } + + hasKey := false + for _, key := range keys { + if _, exists := schema.Properties[key]; exists { + hasKey = true + break + } + } + if !hasKey { + return nil + } + + toolCopy := tool + schemaCopy := *schema + newProps := make(map[string]*jsonschema.Schema, len(schema.Properties)) + for k, v := range schema.Properties { + if !slices.Contains(keys, k) { + newProps[k] = v + } + } + schemaCopy.Properties = newProps + if len(schemaCopy.Required) > 0 { + newRequired := make([]string, 0, len(schemaCopy.Required)) + for _, r := range schemaCopy.Required { + if !slices.Contains(keys, r) { + newRequired = append(newRequired, r) + } + } + schemaCopy.Required = newRequired + } + toolCopy.Tool.InputSchema = &schemaCopy + return &toolCopy +} + +// stripMetaKeys removes the specified Meta keys from a single tool. +// Returns a modified copy if changes were made, nil otherwise. +func stripMetaKeys(tool ServerTool, keys []string) *ServerTool { + if tool.Tool.Meta == nil || len(keys) == 0 { + return nil + } + + // 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 !hasKeys { + return nil + } + + // 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(keys, k) { + newMeta[k] = v + } + } + + if len(newMeta) == 0 { + toolCopy.Tool.Meta = nil + } else { + toolCopy.Tool.Meta = newMeta + } + return &toolCopy +} diff --git a/pkg/inventory/filters.go b/pkg/inventory/filters.go index 533bba552d..fd3579fa6f 100644 --- a/pkg/inventory/filters.go +++ b/pkg/inventory/filters.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "slices" "sort" ) @@ -35,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 { @@ -69,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 { @@ -88,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. @@ -112,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. @@ -129,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) { @@ -139,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. @@ -156,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) { @@ -166,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 } @@ -214,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) - } - } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) - 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..101f8ee944 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,57 @@ 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 and +// UI-capability-gated input-schema properties (e.g. show_ui) removed when +// the client cannot consume them. 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) + tools = stripUIOnlySchemaProperties(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`) and UI-capability-gated input-schema +// properties (e.g. `show_ui`) are 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 bb3337af02..bcdd70f000 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -4,8 +4,12 @@ import ( "context" "encoding/json" "fmt" + "maps" + "slices" "testing" + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) @@ -38,7 +42,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 +51,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 +68,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 +466,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 +627,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 +1051,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 +1063,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 +1173,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 +1198,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 +1488,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 +1658,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 +1688,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 +1719,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 +1748,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 +1773,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)) @@ -1832,3 +1806,729 @@ func TestWithTools_DeprecatedAliasAndFeatureFlag(t *testing.T) { t.Errorf("Flag ON: Expected new_tool (via alias), got %s", availableOn[0].Tool.Name) } } + +// mockToolWithMeta creates a ServerTool with Meta for testing insiders mode +func mockToolWithMeta(name string, toolsetID string, meta map[string]any) ServerTool { + return NewServerTool( + mcp.Tool{ + Name: name, + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: true, + }, + InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), + Meta: meta, + }, + testToolsetMetadata(toolsetID), + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil + }, + ) +} + +func TestWithMCPApps_DisabledStripsUIMetadata(t *testing.T) { + toolWithUI := mockToolWithMeta("tool_with_ui", "toolset1", map[string]any{ + "ui": map[string]any{"html": "
hello
"}, + "description": "kept", + }) + + // Default: MCP Apps is disabled - UI meta should be stripped on registration. + reg := mustBuild(t, NewBuilder().SetTools([]ServerTool{toolWithUI}).WithToolsets([]string{"all"})) + registered := captureRegisteredTools(context.Background(), t, reg) + + require.Len(t, registered, 1) + if registered[0].Meta["ui"] != nil { + t.Errorf("Expected 'ui' meta to be stripped, but it was present") + } + if registered[0].Meta["description"] != "kept" { + t.Errorf("Expected 'description' meta to be preserved, got %v", registered[0].Meta["description"]) + } +} + +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", + }) + + // 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"}). + 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 with MCP Apps enabled") + } + // Other metadata should also be preserved + if available[0].Tool.Meta["description"] != "kept" { + t.Errorf("Expected 'description' meta to be preserved, got %v", available[0].Tool.Meta["description"]) + } +} + +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 MCP Apps disabled (default) - non-UI meta should be unaffected + reg := mustBuild(t, NewBuilder(). + SetTools([]ServerTool{toolNoUI, toolNilMeta}). + WithToolsets([]string{"all"})) + available := reg.AvailableTools(context.Background()) + + require.Len(t, available, 2) + + // Find toolNoUI + var foundNoUI, foundNilMeta *ServerTool + for i := range available { + switch available[i].Tool.Name { + case "tool_no_ui": + foundNoUI = &available[i] + case "tool_nil_meta": + foundNilMeta = &available[i] + } + } + + require.NotNil(t, foundNoUI) + require.NotNil(t, foundNilMeta) + + // toolNoUI should have its metadata preserved + if foundNoUI.Tool.Meta["description"] != "kept" || foundNoUI.Tool.Meta["version"] != "1.0" { + t.Errorf("Expected toolNoUI meta to be unchanged, got %v", foundNoUI.Tool.Meta) + } + + // toolNilMeta should still have nil meta + if foundNilMeta.Tool.Meta != nil { + t.Errorf("Expected toolNilMeta to have nil meta, got %v", foundNilMeta.Tool.Meta) + } +} + +func TestWithMCPApps_UIOnlyMetaBecomesNil(t *testing.T) { + toolUIOnly := mockToolWithMeta("tool_ui_only", "toolset1", map[string]any{ + "ui": map[string]any{"html": "
hello
"}, + }) + + reg := mustBuild(t, NewBuilder(). + SetTools([]ServerTool{toolUIOnly}). + WithToolsets([]string{"all"})) + registered := captureRegisteredTools(context.Background(), t, reg) + + 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 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 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 - 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, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tool := mockToolWithMeta("test", "toolset1", tt.meta) + result := stripMetaKeys(tool, tt.keys) + + if tt.expectChange { + require.NotNil(t, result, "expected change but got nil") + if tt.expectedMeta == nil { + require.Nil(t, result.Tool.Meta, "expected Meta to be nil") + } else { + // Compare values by key since types may differ (map[string]any vs mcp.Meta) + for k, v := range tt.expectedMeta { + require.Equal(t, v, result.Tool.Meta[k], "key %s should match", k) + } + require.Len(t, result.Tool.Meta, len(tt.expectedMeta)) + } + } else { + require.Nil(t, result, "expected no change but got result") + } + }) + } +} + +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 := stripMCPAppsMetadata(tools) + + require.Len(t, result, 3) + + // tool1: ui should be stripped, meta becomes nil + require.Nil(t, result[0].Tool.Meta, "tool1 meta should be nil after stripping ui") + + // tool2: unchanged (compare by key since types differ) + require.Equal(t, "kept", result[1].Tool.Meta["description"]) + require.Len(t, result[1].Tool.Meta, 1) + + // tool3: unchanged (nil) + require.Nil(t, result[2].Tool.Meta) +} + +// mockToolWithSchema creates a ServerTool with the given *jsonschema.Schema as +// InputSchema. Used to exercise schema-based strip helpers. +func mockToolWithSchema(name string, toolsetID string, schema *jsonschema.Schema) ServerTool { + return NewServerTool( + mcp.Tool{ + Name: name, + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + testToolsetMetadata(toolsetID), + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil + }, + ) +} + +func TestStripSchemaProperties(t *testing.T) { + tests := []struct { + name string + schema any + keys []string + expectChange bool + wantProperties []string // property names expected to remain (order-independent) + wantRequired []string // required fields expected to remain (order-independent) + }{ + { + name: "nil schema - no change", + schema: nil, + keys: []string{"show_ui"}, + expectChange: false, + }, + { + name: "RawMessage schema - skipped (not a *jsonschema.Schema)", + schema: json.RawMessage(`{"type":"object","properties":{"show_ui":{"type":"boolean"}}}`), + keys: []string{"show_ui"}, + expectChange: false, + }, + { + name: "schema without the key - no change", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + }, + }, + keys: []string{"show_ui"}, + expectChange: false, + }, + { + name: "empty keys list - no change", + schema: &jsonschema.Schema{Type: "object", Properties: map[string]*jsonschema.Schema{"show_ui": {Type: "boolean"}}}, + keys: []string{}, + expectChange: false, + }, + { + name: "schema with the key - stripped, others preserved", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + "repo": {Type: "string"}, + "show_ui": {Type: "boolean"}, + }, + Required: []string{"owner", "repo"}, + }, + keys: []string{"show_ui"}, + expectChange: true, + wantProperties: []string{"owner", "repo"}, + wantRequired: []string{"owner", "repo"}, + }, + { + name: "key in required list is also stripped", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + "show_ui": {Type: "boolean"}, + }, + Required: []string{"owner", "show_ui"}, + }, + keys: []string{"show_ui"}, + expectChange: true, + wantProperties: []string{"owner"}, + wantRequired: []string{"owner"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tool := NewServerTool( + mcp.Tool{ + Name: "test", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, + InputSchema: tt.schema, + }, + testToolsetMetadata("toolset1"), + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil + }, + ) + + result := stripSchemaProperties(tool, tt.keys) + + if !tt.expectChange { + require.Nil(t, result, "expected no change but got result") + return + } + + require.NotNil(t, result, "expected change but got nil") + schema, ok := result.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "result schema should remain *jsonschema.Schema") + require.ElementsMatch(t, tt.wantProperties, slices.Collect(maps.Keys(schema.Properties))) + require.ElementsMatch(t, tt.wantRequired, schema.Required) + + // Original schema must not be mutated. + origSchema := tt.schema.(*jsonschema.Schema) + _, stillThere := origSchema.Properties["show_ui"] + require.True(t, stillThere || !slices.Contains(tt.keys, "show_ui"), "original schema should not be mutated") + }) + } +} + +func TestStripUIOnlySchemaProperties(t *testing.T) { + tools := []ServerTool{ + mockToolWithSchema("with_show_ui", "toolset1", &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + "show_ui": {Type: "boolean"}, + }, + }), + mockToolWithSchema("without_show_ui", "toolset1", &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + }, + }), + mockTool("raw_schema_tool", "toolset1", true), // InputSchema is json.RawMessage + } + + result := stripUIOnlySchemaProperties(tools) + require.Len(t, result, 3) + + stripped := result[0].Tool.InputSchema.(*jsonschema.Schema) + require.NotContains(t, stripped.Properties, "show_ui", + "show_ui should be stripped from a tool that declares it") + require.Contains(t, stripped.Properties, "owner", + "other properties on the same schema must be preserved") + + // Tool without show_ui: same value returned (no allocation), schema untouched. + require.Same(t, tools[1].Tool.InputSchema, result[1].Tool.InputSchema, + "tools without the gated property must be returned unchanged") + + // Tool with an opaque (json.RawMessage) schema: passed through untouched. + require.Equal(t, tools[2].Tool.InputSchema, result[2].Tool.InputSchema, + "tools with a non-*jsonschema.Schema input schema must be passed through") +} + +// TestConditionalSchemaPropertyDescriptions ensures every property that +// inventory strips per-request also has a human-readable condition the doc +// generator can render. A future addition to uiOnlySchemaProperties that +// forgets to wire a description through will fail here. +func TestConditionalSchemaPropertyDescriptions(t *testing.T) { + t.Parallel() + + descs := ConditionalSchemaPropertyDescriptions() + require.NotEmpty(t, descs, "expected at least show_ui to be advertised as conditional") + + for _, name := range uiOnlySchemaProperties { + desc, ok := descs[name] + require.Truef(t, ok, "ui-only property %q must have a conditional description", name) + require.NotEmptyf(t, desc, "conditional description for %q must be non-empty", name) + } +} + +func TestToolsForRegistration_StripsShowUIUnderSameGate(t *testing.T) { + // A tool whose schema declares both `_meta.ui` and `show_ui`. The strip + // for both must fire — or not — together, governed by the same gate + // already covered by TestShouldStripMCPAppsMetadata. + makeTool := func() ServerTool { + st := mockToolWithSchema("ui_tool", "toolset1", &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + "show_ui": {Type: "boolean"}, + }, + }) + st.Tool.Meta = map[string]any{ + "ui": map[string]any{"resourceUri": "ui://example"}, + "description": "kept", + } + return st + } + + mcpAppsChecker := func(_ context.Context, flag string) (bool, error) { + return flag == mcpAppsFeatureFlag, nil + } + + tests := []struct { + name string + ctx context.Context + ffOn bool + wantShowUI bool // expect show_ui to remain in registered schema + wantUIMeta bool // expect _meta.ui to remain on registered tool + }{ + { + name: "FF off, capability unknown -> both stripped", + ctx: context.Background(), + ffOn: false, + wantShowUI: false, + wantUIMeta: false, + }, + { + name: "FF on, capability unknown -> both kept", + ctx: context.Background(), + ffOn: true, + wantShowUI: true, + wantUIMeta: true, + }, + { + name: "FF on, capability present -> both kept", + ctx: ghcontext.WithUISupport(context.Background(), true), + ffOn: true, + wantShowUI: true, + wantUIMeta: true, + }, + { + name: "FF on, capability explicitly absent -> both stripped", + ctx: ghcontext.WithUISupport(context.Background(), false), + ffOn: true, + wantShowUI: false, + wantUIMeta: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + builder := NewBuilder().SetTools([]ServerTool{makeTool()}).WithToolsets([]string{"all"}) + if tc.ffOn { + builder = builder.WithFeatureChecker(mcpAppsChecker) + } + reg := mustBuild(t, builder) + + registered := reg.ToolsForRegistration(tc.ctx) + require.Len(t, registered, 1) + schema, ok := registered[0].Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok) + + _, hasShowUI := schema.Properties["show_ui"] + require.Equal(t, tc.wantShowUI, hasShowUI, + "show_ui presence in registered schema should match strip gate") + + _, hasUIMeta := registered[0].Tool.Meta["ui"] + require.Equal(t, tc.wantUIMeta, hasUIMeta, + "_meta.ui presence on registered tool should match strip gate") + }) + } +} + +func TestStripMetaKeys_MultipleKeys(t *testing.T) { + // This test verifies the mechanism works for multiple keys + keys := []string{"ui", "experimental_feature", "beta"} + + tool := mockToolWithMeta("test", "toolset1", map[string]any{ + "ui": "ui data", + "experimental_feature": "exp data", + "beta": "beta data", + "description": "kept", + }) + + result := stripMetaKeys(tool, keys) + + require.NotNil(t, result) + require.NotNil(t, result.Tool.Meta) + require.Nil(t, result.Tool.Meta["ui"], "ui should be stripped") + require.Nil(t, result.Tool.Meta["experimental_feature"], "experimental_feature should be stripped") + require.Nil(t, result.Tool.Meta["beta"], "beta should be stripped") + require.Equal(t, "kept", result.Tool.Meta["description"], "description should be preserved") +} + +func TestWithMCPApps_DoesNotMutateOriginalTools(t *testing.T) { + originalMeta := map[string]any{"ui": "data", "description": "kept"} + tool := mockToolWithMeta("test", "toolset1", originalMeta) + tools := []ServerTool{tool} + + // Build with MCP Apps disabled (default) - should strip ui + _ = mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) + + // Original tool should be unchanged + require.Equal(t, "data", tools[0].Tool.Meta["ui"], "original tool should not be mutated") + require.Equal(t, "kept", tools[0].Tool.Meta["description"], "original tool should not be mutated") +} + +func TestWithExcludeTools(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), + mockTool("tool2", "toolset1", true), + mockTool("tool3", "toolset2", true), + } + + tests := []struct { + name string + excluded []string + toolsets []string + expectedNames []string + unexpectedNames []string + }{ + { + name: "single tool excluded", + excluded: []string{"tool2"}, + toolsets: []string{"all"}, + expectedNames: []string{"tool1", "tool3"}, + unexpectedNames: []string{"tool2"}, + }, + { + name: "multiple tools excluded", + excluded: []string{"tool1", "tool3"}, + toolsets: []string{"all"}, + expectedNames: []string{"tool2"}, + unexpectedNames: []string{"tool1", "tool3"}, + }, + { + name: "empty excluded list is a no-op", + excluded: []string{}, + toolsets: []string{"all"}, + expectedNames: []string{"tool1", "tool2", "tool3"}, + unexpectedNames: nil, + }, + { + name: "nil excluded list is a no-op", + excluded: nil, + toolsets: []string{"all"}, + expectedNames: []string{"tool1", "tool2", "tool3"}, + unexpectedNames: nil, + }, + { + name: "excluding non-existent tool is a no-op", + excluded: []string{"nonexistent"}, + toolsets: []string{"all"}, + expectedNames: []string{"tool1", "tool2", "tool3"}, + unexpectedNames: nil, + }, + { + name: "exclude all tools", + excluded: []string{"tool1", "tool2", "tool3"}, + toolsets: []string{"all"}, + expectedNames: nil, + unexpectedNames: []string{"tool1", "tool2", "tool3"}, + }, + { + name: "whitespace is trimmed", + excluded: []string{" tool2 ", " tool3 "}, + toolsets: []string{"all"}, + expectedNames: []string{"tool1"}, + unexpectedNames: []string{"tool2", "tool3"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := mustBuild(t, NewBuilder(). + SetTools(tools). + WithToolsets(tt.toolsets). + WithExcludeTools(tt.excluded)) + + available := reg.AvailableTools(context.Background()) + names := make(map[string]bool) + for _, tool := range available { + names[tool.Tool.Name] = true + } + + for _, expected := range tt.expectedNames { + require.True(t, names[expected], "tool %q should be available", expected) + } + for _, unexpected := range tt.unexpectedNames { + require.False(t, names[unexpected], "tool %q should be excluded", unexpected) + } + }) + } +} + +func TestWithExcludeTools_OverridesAdditionalTools(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), + mockTool("tool2", "toolset1", true), + mockTool("tool3", "toolset2", true), + } + + // tool3 is explicitly enabled via WithTools, but also excluded + // excluded should win because builder filters run before additional tools check + reg := mustBuild(t, NewBuilder(). + SetTools(tools). + WithToolsets([]string{"toolset1"}). + WithTools([]string{"tool3"}). + WithExcludeTools([]string{"tool3"})) + + available := reg.AvailableTools(context.Background()) + names := make(map[string]bool) + for _, tool := range available { + names[tool.Tool.Name] = true + } + + require.True(t, names["tool1"], "tool1 should be available") + require.True(t, names["tool2"], "tool2 should be available") + require.False(t, names["tool3"], "tool3 should be excluded even though explicitly added via WithTools") +} + +func TestWithExcludeTools_CombinesWithReadOnly(t *testing.T) { + tools := []ServerTool{ + mockTool("read_tool", "toolset1", true), + mockTool("write_tool", "toolset1", false), + mockTool("another_read", "toolset1", true), + } + + // read-only excludes write_tool, exclude-tools excludes read_tool + reg := mustBuild(t, NewBuilder(). + SetTools(tools). + WithToolsets([]string{"all"}). + WithReadOnly(true). + WithExcludeTools([]string{"read_tool"})) + + available := reg.AvailableTools(context.Background()) + require.Len(t, available, 1) + require.Equal(t, "another_read", available[0].Tool.Name) +} + +func TestCreateExcludeToolsFilter(t *testing.T) { + filter := CreateExcludeToolsFilter([]string{"blocked_tool"}) + + blockedTool := mockTool("blocked_tool", "toolset1", true) + allowedTool := mockTool("allowed_tool", "toolset1", true) + + allowed, err := filter(context.Background(), &blockedTool) + require.NoError(t, err) + require.False(t, allowed, "blocked_tool should be excluded") + + allowed, err = filter(context.Background(), &allowedTool) + 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). It delegates +// to ToolsForRegistration so any future strip added there is picked up +// automatically. +func captureRegisteredTools(ctx context.Context, t *testing.T, reg *Inventory) []*mcp.Tool { + t.Helper() + forReg := reg.ToolsForRegistration(ctx) + out := make([]*mcp.Tool, 0, len(forReg)) + for i := range forReg { + toolCopy := forReg[i].Tool + out = append(out, &toolCopy) + } + 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 752a4c2bd0..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 @@ -118,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. @@ -157,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 @@ -166,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 80eca07f87..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]interface{}{ - "owner": githubv4.String(owner), - "name": githubv4.String(repo), - "username": githubv4.String(username), + variables := map[string]any{ + "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/octicons/icons/code-square-dark.png b/pkg/octicons/icons/code-square-dark.png new file mode 100644 index 0000000000..8e2d8d0c98 Binary files /dev/null and b/pkg/octicons/icons/code-square-dark.png differ diff --git a/pkg/octicons/icons/code-square-light.png b/pkg/octicons/icons/code-square-light.png new file mode 100644 index 0000000000..bccf0006a0 Binary files /dev/null and b/pkg/octicons/icons/code-square-light.png differ diff --git a/pkg/octicons/required_icons.txt b/pkg/octicons/required_icons.txt index 7911b46eb8..15dc444956 100644 --- a/pkg/octicons/required_icons.txt +++ b/pkg/octicons/required_icons.txt @@ -19,6 +19,7 @@ bell book check-circle codescan +code-square comment-discussion copilot dependabot diff --git a/pkg/raw/raw.go b/pkg/raw/raw.go index 10bade5eb2..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/v79/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 interface{}, 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 4c4aa33b4a..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/v79/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/scopes/fetcher.go b/pkg/scopes/fetcher.go index 48e0001796..b372455031 100644 --- a/pkg/scopes/fetcher.go +++ b/pkg/scopes/fetcher.go @@ -7,6 +7,9 @@ import ( "net/url" "strings" "time" + + "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/pkg/utils" ) // OAuthScopesHeader is the HTTP response header containing the token's OAuth scopes. @@ -23,28 +26,27 @@ type FetcherOptions struct { // APIHost is the GitHub API host (e.g., "https://api.github.com"). // Defaults to "https://api.github.com" if empty. - APIHost string + APIHost utils.APIHostResolver +} + +type FetcherInterface interface { + FetchTokenScopes(ctx context.Context, token string) ([]string, error) } // Fetcher retrieves token scopes from GitHub's API. // It uses an HTTP HEAD request to minimize bandwidth since we only need headers. type Fetcher struct { client *http.Client - apiHost string + apiHost utils.APIHostResolver } // NewFetcher creates a new scope fetcher with the given options. -func NewFetcher(opts FetcherOptions) *Fetcher { +func NewFetcher(apiHost utils.APIHostResolver, opts FetcherOptions) *Fetcher { client := opts.HTTPClient if client == nil { client = &http.Client{Timeout: DefaultFetchTimeout} } - apiHost := opts.APIHost - if apiHost == "" { - apiHost = "https://api.github.com" - } - return &Fetcher{ client: client, apiHost: apiHost, @@ -61,8 +63,13 @@ func NewFetcher(opts FetcherOptions) *Fetcher { // Note: Fine-grained PATs don't return the X-OAuth-Scopes header, so an empty // slice is returned for those tokens. func (f *Fetcher) FetchTokenScopes(ctx context.Context, token string) ([]string, error) { + apiHostURL, err := f.apiHost.BaseRESTURL(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get API host URL: %w", err) + } + // Use a lightweight endpoint that requires authentication - endpoint, err := url.JoinPath(f.apiHost, "/") + endpoint, err := url.JoinPath(apiHostURL.String(), "/") if err != nil { return nil, fmt.Errorf("failed to construct API URL: %w", err) } @@ -72,9 +79,9 @@ func (f *Fetcher) FetchTokenScopes(ctx context.Context, token string) ([]string, return nil, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set(headers.AuthorizationHeader, "Bearer "+token) + req.Header.Set(headers.AcceptHeader, "application/vnd.github+json") + req.Header.Set(headers.GitHubAPIVersionHeader, "2022-11-28") resp, err := f.client.Do(req) if err != nil { @@ -115,11 +122,16 @@ func ParseScopeHeader(header string) []string { // FetchTokenScopes is a convenience function that creates a default fetcher // and fetches the token scopes. func FetchTokenScopes(ctx context.Context, token string) ([]string, error) { - return NewFetcher(FetcherOptions{}).FetchTokenScopes(ctx, token) + apiHost, err := utils.NewAPIHost("https://api.github.com/") + if err != nil { + return nil, fmt.Errorf("failed to create default API host: %w", err) + } + + return NewFetcher(apiHost, FetcherOptions{}).FetchTokenScopes(ctx, token) } // FetchTokenScopesWithHost is a convenience function that creates a fetcher // for a specific API host and fetches the token scopes. -func FetchTokenScopesWithHost(ctx context.Context, token, apiHost string) ([]string, error) { - return NewFetcher(FetcherOptions{APIHost: apiHost}).FetchTokenScopes(ctx, token) +func FetchTokenScopesWithHost(ctx context.Context, token string, apiHost utils.APIHostResolver) ([]string, error) { + return NewFetcher(apiHost, FetcherOptions{}).FetchTokenScopes(ctx, token) } diff --git a/pkg/scopes/fetcher_test.go b/pkg/scopes/fetcher_test.go index 13feab5b0f..7ef910a569 100644 --- a/pkg/scopes/fetcher_test.go +++ b/pkg/scopes/fetcher_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "net/url" "testing" "time" @@ -11,6 +12,26 @@ import ( "github.com/stretchr/testify/require" ) +type testAPIHostResolver struct { + baseURL string +} + +func (t testAPIHostResolver) BaseRESTURL(_ context.Context) (*url.URL, error) { + return url.Parse(t.baseURL) +} +func (t testAPIHostResolver) GraphqlURL(_ context.Context) (*url.URL, error) { + return nil, nil +} +func (t testAPIHostResolver) UploadURL(_ context.Context) (*url.URL, error) { + return nil, nil +} +func (t testAPIHostResolver) RawURL(_ context.Context) (*url.URL, error) { + return nil, nil +} +func (t testAPIHostResolver) AuthorizationServerURL(_ context.Context) (*url.URL, error) { + return nil, nil +} + func TestParseScopeHeader(t *testing.T) { tests := []struct { name string @@ -146,10 +167,8 @@ func TestFetcher_FetchTokenScopes(t *testing.T) { t.Run(tt.name, func(t *testing.T) { server := httptest.NewServer(tt.handler) defer server.Close() - - fetcher := NewFetcher(FetcherOptions{ - APIHost: server.URL, - }) + apiHost := testAPIHostResolver{baseURL: server.URL} + fetcher := NewFetcher(apiHost, FetcherOptions{}) scopes, err := fetcher.FetchTokenScopes(context.Background(), "test-token") @@ -167,10 +186,13 @@ func TestFetcher_FetchTokenScopes(t *testing.T) { } func TestFetcher_DefaultOptions(t *testing.T) { - fetcher := NewFetcher(FetcherOptions{}) + apiHost := testAPIHostResolver{baseURL: "https://api.github.com"} + fetcher := NewFetcher(apiHost, FetcherOptions{}) // Verify default API host is set - assert.Equal(t, "https://api.github.com", fetcher.apiHost) + apiURL, err := fetcher.apiHost.BaseRESTURL(context.Background()) + require.NoError(t, err) + assert.Equal(t, "https://api.github.com", apiURL.String()) // Verify default HTTP client is set with timeout assert.NotNil(t, fetcher.client) @@ -180,7 +202,8 @@ func TestFetcher_DefaultOptions(t *testing.T) { func TestFetcher_CustomHTTPClient(t *testing.T) { customClient := &http.Client{Timeout: 5 * time.Second} - fetcher := NewFetcher(FetcherOptions{ + apiHost := testAPIHostResolver{baseURL: "https://api.github.com"} + fetcher := NewFetcher(apiHost, FetcherOptions{ HTTPClient: customClient, }) @@ -188,11 +211,12 @@ func TestFetcher_CustomHTTPClient(t *testing.T) { } func TestFetcher_CustomAPIHost(t *testing.T) { - fetcher := NewFetcher(FetcherOptions{ - APIHost: "https://api.github.enterprise.com", - }) + apiHost := testAPIHostResolver{baseURL: "https://api.github.enterprise.com"} + fetcher := NewFetcher(apiHost, FetcherOptions{}) - assert.Equal(t, "https://api.github.enterprise.com", fetcher.apiHost) + apiURL, err := fetcher.apiHost.BaseRESTURL(context.Background()) + require.NoError(t, err) + assert.Equal(t, "https://api.github.enterprise.com", apiURL.String()) } func TestFetcher_ContextCancellation(t *testing.T) { @@ -202,9 +226,8 @@ func TestFetcher_ContextCancellation(t *testing.T) { })) defer server.Close() - fetcher := NewFetcher(FetcherOptions{ - APIHost: server.URL, - }) + apiHost := testAPIHostResolver{baseURL: server.URL} + fetcher := NewFetcher(apiHost, FetcherOptions{}) ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately diff --git a/pkg/scopes/map.go b/pkg/scopes/map.go new file mode 100644 index 0000000000..3c98338347 --- /dev/null +++ b/pkg/scopes/map.go @@ -0,0 +1,129 @@ +package scopes + +import "github.com/github/github-mcp-server/pkg/inventory" + +// ToolScopeMap maps tool names to their scope requirements. +type ToolScopeMap map[string]*ToolScopeInfo + +// ToolScopeInfo contains scope information for a single tool. +type ToolScopeInfo struct { + // RequiredScopes contains the scopes that are directly required by this tool. + RequiredScopes []string + + // AcceptedScopes contains all scopes that satisfy the requirements (including parent scopes). + AcceptedScopes []string +} + +// globalToolScopeMap is populated from inventory when SetToolScopeMapFromInventory is called +var globalToolScopeMap ToolScopeMap + +// SetToolScopeMapFromInventory builds and stores a tool scope map from an inventory. +// This should be called after building the inventory to make scopes available for middleware. +func SetToolScopeMapFromInventory(inv *inventory.Inventory) { + globalToolScopeMap = GetToolScopeMapFromInventory(inv) +} + +// SetGlobalToolScopeMap sets the global tool scope map directly. +// This is useful for testing when you don't have a full inventory. +func SetGlobalToolScopeMap(m ToolScopeMap) { + globalToolScopeMap = m +} + +// GetToolScopeMap returns the global tool scope map. +// Returns an empty map if SetToolScopeMapFromInventory hasn't been called yet. +func GetToolScopeMap() (ToolScopeMap, error) { + if globalToolScopeMap == nil { + return make(ToolScopeMap), nil + } + return globalToolScopeMap, nil +} + +// GetToolScopeInfo returns scope information for a specific tool from the global scope map. +func GetToolScopeInfo(toolName string) (*ToolScopeInfo, error) { + m, err := GetToolScopeMap() + if err != nil { + return nil, err + } + return m[toolName], nil +} + +// GetToolScopeMapFromInventory builds a tool scope map from an inventory. +// This extracts scope information from ServerTool.RequiredScopes and ServerTool.AcceptedScopes. +func GetToolScopeMapFromInventory(inv *inventory.Inventory) ToolScopeMap { + result := make(ToolScopeMap) + + // Get all tools from the inventory (both enabled and disabled) + // We need all tools for scope checking purposes + allTools := inv.AllTools() + for i := range allTools { + tool := &allTools[i] + if len(tool.RequiredScopes) > 0 || len(tool.AcceptedScopes) > 0 { + result[tool.Tool.Name] = &ToolScopeInfo{ + RequiredScopes: tool.RequiredScopes, + AcceptedScopes: tool.AcceptedScopes, + } + } + } + + return result +} + +// HasAcceptedScope checks if any of the provided user scopes satisfy the tool's requirements. +func (t *ToolScopeInfo) HasAcceptedScope(userScopes ...string) bool { + if t == nil || len(t.AcceptedScopes) == 0 { + return true // No scopes required + } + + userScopeSet := make(map[string]bool) + for _, scope := range userScopes { + userScopeSet[scope] = true + } + + for _, scope := range t.AcceptedScopes { + if userScopeSet[scope] { + return true + } + } + return false +} + +// MissingScopes returns the required scopes that are not present in the user's scopes. +func (t *ToolScopeInfo) MissingScopes(userScopes ...string) []string { + if t == nil || len(t.RequiredScopes) == 0 { + return nil + } + + // Create a set of user scopes for O(1) lookup + userScopeSet := make(map[string]bool, len(userScopes)) + for _, s := range userScopes { + userScopeSet[s] = true + } + + // Check if any accepted scope is present + hasAccepted := false + for _, scope := range t.AcceptedScopes { + if userScopeSet[scope] { + hasAccepted = true + break + } + } + + if hasAccepted { + return nil // User has sufficient scopes + } + + // Return required scopes as the minimum needed + missing := make([]string, len(t.RequiredScopes)) + copy(missing, t.RequiredScopes) + return missing +} + +// GetRequiredScopesSlice returns the required scopes as a slice of strings. +func (t *ToolScopeInfo) GetRequiredScopesSlice() []string { + if t == nil { + return nil + } + scopes := make([]string, len(t.RequiredScopes)) + copy(scopes, t.RequiredScopes) + return scopes +} diff --git a/pkg/scopes/map_test.go b/pkg/scopes/map_test.go new file mode 100644 index 0000000000..5f33cdda2b --- /dev/null +++ b/pkg/scopes/map_test.go @@ -0,0 +1,194 @@ +package scopes + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetToolScopeMap(t *testing.T) { + // Reset and set up a test map + SetGlobalToolScopeMap(ToolScopeMap{ + "test_tool": &ToolScopeInfo{ + RequiredScopes: []string{"read:org"}, + AcceptedScopes: []string{"read:org", "write:org", "admin:org"}, + }, + }) + + m, err := GetToolScopeMap() + require.NoError(t, err) + require.NotNil(t, m) + require.Greater(t, len(m), 0, "expected at least one tool in the scope map") + + testTool, ok := m["test_tool"] + require.True(t, ok, "expected test_tool to be in the scope map") + assert.Contains(t, testTool.RequiredScopes, "read:org") + assert.Contains(t, testTool.AcceptedScopes, "read:org") + assert.Contains(t, testTool.AcceptedScopes, "admin:org") +} + +func TestGetToolScopeInfo(t *testing.T) { + // Set up test scope map + SetGlobalToolScopeMap(ToolScopeMap{ + "search_orgs": &ToolScopeInfo{ + RequiredScopes: []string{"read:org"}, + AcceptedScopes: []string{"read:org", "write:org", "admin:org"}, + }, + }) + + info, err := GetToolScopeInfo("search_orgs") + require.NoError(t, err) + require.NotNil(t, info) + + // Non-existent tool should return nil + info, err = GetToolScopeInfo("nonexistent_tool") + require.NoError(t, err) + assert.Nil(t, info) +} + +func TestToolScopeInfo_HasAcceptedScope(t *testing.T) { + testCases := []struct { + name string + scopeInfo *ToolScopeInfo + userScopes []string + expected bool + }{ + { + name: "has exact required scope", + scopeInfo: &ToolScopeInfo{ + RequiredScopes: []string{"read:org"}, + AcceptedScopes: []string{"read:org", "write:org", "admin:org"}, + }, + userScopes: []string{"read:org"}, + expected: true, + }, + { + name: "has parent scope (admin:org grants read:org)", + scopeInfo: &ToolScopeInfo{ + RequiredScopes: []string{"read:org"}, + AcceptedScopes: []string{"read:org", "write:org", "admin:org"}, + }, + userScopes: []string{"admin:org"}, + expected: true, + }, + { + name: "has parent scope (write:org grants read:org)", + scopeInfo: &ToolScopeInfo{ + RequiredScopes: []string{"read:org"}, + AcceptedScopes: []string{"read:org", "write:org", "admin:org"}, + }, + userScopes: []string{"write:org"}, + expected: true, + }, + { + name: "missing required scope", + scopeInfo: &ToolScopeInfo{ + RequiredScopes: []string{"read:org"}, + AcceptedScopes: []string{"read:org", "write:org", "admin:org"}, + }, + userScopes: []string{"repo"}, + expected: false, + }, + { + name: "no scope required", + scopeInfo: &ToolScopeInfo{ + RequiredScopes: []string{}, + AcceptedScopes: []string{}, + }, + userScopes: []string{}, + expected: true, + }, + { + name: "nil scope info", + scopeInfo: nil, + userScopes: []string{}, + expected: true, + }, + { + name: "repo scope for tool requiring repo", + scopeInfo: &ToolScopeInfo{ + RequiredScopes: []string{"repo"}, + AcceptedScopes: []string{"repo"}, + }, + userScopes: []string{"repo"}, + expected: true, + }, + { + name: "missing repo scope", + scopeInfo: &ToolScopeInfo{ + RequiredScopes: []string{"repo"}, + AcceptedScopes: []string{"repo"}, + }, + userScopes: []string{"public_repo"}, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := tc.scopeInfo.HasAcceptedScope(tc.userScopes...) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestToolScopeInfo_MissingScopes(t *testing.T) { + testCases := []struct { + name string + scopeInfo *ToolScopeInfo + userScopes []string + expectedLen int + expectedScopes []string + }{ + { + name: "has required scope - no missing", + scopeInfo: &ToolScopeInfo{ + RequiredScopes: []string{"read:org"}, + AcceptedScopes: []string{"read:org", "write:org", "admin:org"}, + }, + userScopes: []string{"read:org"}, + expectedLen: 0, + expectedScopes: nil, + }, + { + name: "missing scope", + scopeInfo: &ToolScopeInfo{ + RequiredScopes: []string{"read:org"}, + AcceptedScopes: []string{"read:org", "write:org", "admin:org"}, + }, + userScopes: []string{"repo"}, + expectedLen: 1, + expectedScopes: []string{"read:org"}, + }, + { + name: "no scope required - no missing", + scopeInfo: &ToolScopeInfo{ + RequiredScopes: []string{}, + AcceptedScopes: []string{}, + }, + userScopes: []string{}, + expectedLen: 0, + expectedScopes: nil, + }, + { + name: "nil scope info - no missing", + scopeInfo: nil, + userScopes: []string{}, + expectedLen: 0, + expectedScopes: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + missing := tc.scopeInfo.MissingScopes(tc.userScopes...) + assert.Len(t, missing, tc.expectedLen) + if tc.expectedScopes != nil { + for _, expected := range tc.expectedScopes { + assert.Contains(t, missing, expected) + } + } + }) + } +} diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go index a9b06e9880..cb1b7681a7 100644 --- a/pkg/scopes/scopes.go +++ b/pkg/scopes/scopes.go @@ -1,6 +1,9 @@ package scopes -import "sort" +import ( + "slices" + "sort" +) // Scope represents a GitHub OAuth scope. // These constants define all OAuth scopes used by the GitHub MCP server tools. @@ -88,9 +91,7 @@ func (s ScopeSet) ToSlice() []Scope { scopes = append(scopes, scope) } // Sort for deterministic output - sort.Slice(scopes, func(i, j int) bool { - return scopes[i] < scopes[j] - }) + slices.Sort(scopes) return scopes } diff --git a/pkg/tooldiscovery/search.go b/pkg/tooldiscovery/search.go index e7adc029b2..e46b028504 100644 --- a/pkg/tooldiscovery/search.go +++ b/pkg/tooldiscovery/search.go @@ -291,10 +291,7 @@ func normalizedSimilarity(a, b string) float64 { } distance := fuzzy.LevenshteinDistance(a, b) - maxLen := len(a) - if len(b) > maxLen { - maxLen = len(b) - } + maxLen := max(len(b), len(a)) similarity := 1 - (float64(distance) / float64(maxLen)) if similarity < 0 { 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/pkg/utils/api.go b/pkg/utils/api.go new file mode 100644 index 0000000000..ae3a9afc30 --- /dev/null +++ b/pkg/utils/api.go @@ -0,0 +1,247 @@ +package utils //nolint:revive //TODO: figure out a better name for this package + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +type APIHostResolver interface { + BaseRESTURL(ctx context.Context) (*url.URL, error) + GraphqlURL(ctx context.Context) (*url.URL, error) + UploadURL(ctx context.Context) (*url.URL, error) + RawURL(ctx context.Context) (*url.URL, error) + AuthorizationServerURL(ctx context.Context) (*url.URL, error) +} + +type APIHost struct { + restURL *url.URL + gqlURL *url.URL + uploadURL *url.URL + rawURL *url.URL + authorizationServerURL *url.URL +} + +var _ APIHostResolver = APIHost{} + +func NewAPIHost(s string) (APIHostResolver, error) { + a, err := parseAPIHost(s) + + if err != nil { + return nil, err + } + + return a, nil +} + +// APIHostResolver implementation +func (a APIHost) BaseRESTURL(_ context.Context) (*url.URL, error) { + return a.restURL, nil +} + +func (a APIHost) GraphqlURL(_ context.Context) (*url.URL, error) { + return a.gqlURL, nil +} + +func (a APIHost) UploadURL(_ context.Context) (*url.URL, error) { + return a.uploadURL, nil +} + +func (a APIHost) RawURL(_ context.Context) (*url.URL, error) { + return a.rawURL, nil +} + +func (a APIHost) AuthorizationServerURL(_ context.Context) (*url.URL, error) { + return a.authorizationServerURL, nil +} + +func newDotcomHost() (APIHost, error) { + baseRestURL, err := url.Parse("https://api.github.com/") + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse dotcom REST URL: %w", err) + } + + gqlURL, err := url.Parse("https://api.github.com/graphql") + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse dotcom GraphQL URL: %w", err) + } + + uploadURL, err := url.Parse("https://uploads.github.com") + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse dotcom Upload URL: %w", err) + } + + rawURL, err := url.Parse("https://raw.githubusercontent.com/") + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse dotcom Raw URL: %w", err) + } + + // The authorization server for GitHub.com is at github.com/login/oauth, not api.github.com + authorizationServerURL, err := url.Parse("https://github.com/login/oauth") + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse dotcom Authorization Server URL: %w", err) + } + + return APIHost{ + restURL: baseRestURL, + gqlURL: gqlURL, + uploadURL: uploadURL, + rawURL: rawURL, + authorizationServerURL: authorizationServerURL, + }, nil +} + +func newGHECHost(hostname string) (APIHost, error) { + u, err := url.Parse(hostname) + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse GHEC URL: %w", err) + } + + // Unsecured GHEC would be an error + if u.Scheme == "http" { + return APIHost{}, fmt.Errorf("GHEC URL must be HTTPS") + } + + restURL, err := url.Parse(fmt.Sprintf("https://api.%s/", u.Hostname())) + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse GHEC REST URL: %w", err) + } + + gqlURL, err := url.Parse(fmt.Sprintf("https://api.%s/graphql", u.Hostname())) + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse GHEC GraphQL URL: %w", err) + } + + uploadURL, err := url.Parse(fmt.Sprintf("https://uploads.%s/", u.Hostname())) + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse GHEC Upload URL: %w", err) + } + + rawURL, err := url.Parse(fmt.Sprintf("https://raw.%s/", u.Hostname())) + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse GHEC Raw URL: %w", err) + } + + authorizationServerURL, err := url.Parse(fmt.Sprintf("https://%s/login/oauth", u.Hostname())) + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse GHEC Authorization Server URL: %w", err) + } + + return APIHost{ + restURL: restURL, + gqlURL: gqlURL, + uploadURL: uploadURL, + rawURL: rawURL, + authorizationServerURL: authorizationServerURL, + }, nil +} + +func newGHESHost(hostname string) (APIHost, error) { + u, err := url.Parse(hostname) + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse GHES URL: %w", err) + } + + restURL, err := url.Parse(fmt.Sprintf("%s://%s/api/v3/", u.Scheme, u.Hostname())) + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse GHES REST URL: %w", err) + } + + gqlURL, err := url.Parse(fmt.Sprintf("%s://%s/api/graphql", u.Scheme, u.Hostname())) + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse GHES GraphQL URL: %w", err) + } + + // Check if subdomain isolation is enabled + // See https://docs.github.com/en/enterprise-server@3.17/admin/configuring-settings/hardening-security-for-your-enterprise/enabling-subdomain-isolation#about-subdomain-isolation + hasSubdomainIsolation := checkSubdomainIsolation(u.Scheme, u.Hostname()) + + var uploadURL *url.URL + if hasSubdomainIsolation { + // With subdomain isolation: https://uploads.hostname/ + uploadURL, err = url.Parse(fmt.Sprintf("%s://uploads.%s/", u.Scheme, u.Hostname())) + } else { + // Without subdomain isolation: https://hostname/api/uploads/ + uploadURL, err = url.Parse(fmt.Sprintf("%s://%s/api/uploads/", u.Scheme, u.Hostname())) + } + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse GHES Upload URL: %w", err) + } + + var rawURL *url.URL + if hasSubdomainIsolation { + // With subdomain isolation: https://raw.hostname/ + rawURL, err = url.Parse(fmt.Sprintf("%s://raw.%s/", u.Scheme, u.Hostname())) + } else { + // Without subdomain isolation: https://hostname/raw/ + rawURL, err = url.Parse(fmt.Sprintf("%s://%s/raw/", u.Scheme, u.Hostname())) + } + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse GHES Raw URL: %w", err) + } + + authorizationServerURL, err := url.Parse(fmt.Sprintf("%s://%s/login/oauth", u.Scheme, u.Hostname())) + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse GHES Authorization Server URL: %w", err) + } + + return APIHost{ + restURL: restURL, + gqlURL: gqlURL, + uploadURL: uploadURL, + rawURL: rawURL, + authorizationServerURL: authorizationServerURL, + }, nil +} + +// checkSubdomainIsolation detects if GitHub Enterprise Server has subdomain isolation enabled +// by attempting to ping the raw./_ping endpoint on the subdomain. The raw subdomain must always exist for subdomain isolation. +func checkSubdomainIsolation(scheme, hostname string) bool { + subdomainURL := fmt.Sprintf("%s://raw.%s/_ping", scheme, hostname) + + client := &http.Client{ + Timeout: 5 * time.Second, + // Don't follow redirects - we just want to check if the endpoint exists + //nolint:revive // parameters are required by http.Client.CheckRedirect signature + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + resp, err := client.Get(subdomainURL) + if err != nil { + return false + } + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK +} + +// Note that this does not handle ports yet, so development environments are out. +func parseAPIHost(s string) (APIHost, error) { + if s == "" { + return newDotcomHost() + } + + u, err := url.Parse(s) + if err != nil { + return APIHost{}, fmt.Errorf("could not parse host as URL: %s", s) + } + + if u.Scheme == "" { + return APIHost{}, fmt.Errorf("host must have a scheme (http or https): %s", s) + } + + if u.Hostname() == "github.com" || strings.HasSuffix(u.Hostname(), ".github.com") { + return newDotcomHost() + } + + if u.Hostname() == "ghe.com" || strings.HasSuffix(u.Hostname(), ".ghe.com") { + return newGHECHost(s) + } + + return newGHESHost(s) +} diff --git a/pkg/utils/api_test.go b/pkg/utils/api_test.go new file mode 100644 index 0000000000..40fcb8f26a --- /dev/null +++ b/pkg/utils/api_test.go @@ -0,0 +1,75 @@ +package utils //nolint:revive //TODO: figure out a better name for this package + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseAPIHost(t *testing.T) { + tests := []struct { + name string + input string + wantRestURL string + wantErr bool + }{ + { + name: "empty string defaults to dotcom", + input: "", + wantRestURL: "https://api.github.com/", + }, + { + name: "github.com hostname", + input: "https://github.com", + wantRestURL: "https://api.github.com/", + }, + { + name: "subdomain of github.com", + input: "https://foo.github.com", + wantRestURL: "https://api.github.com/", + }, + { + name: "hostname ending in github.com but not a subdomain", + input: "https://mycompanygithub.com", + wantRestURL: "https://mycompanygithub.com/api/v3/", + }, + { + name: "hostname ending in notgithub.com", + input: "https://notgithub.com", + wantRestURL: "https://notgithub.com/api/v3/", + }, + { + name: "ghe.com hostname", + input: "https://ghe.com", + wantRestURL: "https://api.ghe.com/", + }, + { + name: "subdomain of ghe.com", + input: "https://mycompany.ghe.com", + wantRestURL: "https://api.mycompany.ghe.com/", + }, + { + name: "hostname ending in ghe.com but not a subdomain", + input: "https://myghe.com", + wantRestURL: "https://myghe.com/api/v3/", + }, + { + name: "missing scheme", + input: "github.com", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + host, err := parseAPIHost(tc.input) + if tc.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantRestURL, host.restURL.String()) + }) + } +} diff --git a/pkg/utils/result.go b/pkg/utils/result.go index 533fe0573d..99c37602bc 100644 --- a/pkg/utils/result.go +++ b/pkg/utils/result.go @@ -47,3 +47,39 @@ func NewToolResultResource(message string, contents *mcp.ResourceContents) *mcp. IsError: false, } } + +func NewToolResultResourceLink(message string, link *mcp.ResourceLink) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: message, + }, + link, + }, + IsError: false, + } +} + +// NewToolResultAwaitingFormSubmission signals to the agent that a tool call +// has been intercepted to show an MCP App form to the user and has NOT +// performed the requested operation. The agent must stop, not chain dependent +// tool calls, and not claim the operation succeeded. The result is marked +// IsError=true so agents that bail on error don't proceed; the host still +// renders the UI because rendering is keyed off the tool's _meta.ui, not the +// result. The MCP App form will submit the operation directly when the user +// clicks submit, after which a ui/update-model-context call delivers the real +// outcome to the agent. +func NewToolResultAwaitingFormSubmission(message string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: message, + }, + }, + StructuredContent: map[string]any{ + "status": "awaiting_user_submission", + "reason": "An interactive form is being shown to the user. The operation has not been performed.", + }, + IsError: true, + } +} diff --git a/pkg/utils/token.go b/pkg/utils/token.go new file mode 100644 index 0000000000..8933fb0bda --- /dev/null +++ b/pkg/utils/token.go @@ -0,0 +1,75 @@ +package utils //nolint:revive //TODO: figure out a better name for this package + +import ( + "fmt" + "net/http" + "regexp" + "strings" + + httpheaders "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/pkg/http/mark" +) + +type TokenType int + +const ( + TokenTypeUnknown TokenType = iota + TokenTypePersonalAccessToken + TokenTypeFineGrainedPersonalAccessToken + TokenTypeOAuthAccessToken + TokenTypeUserToServerGitHubAppToken + TokenTypeServerToServerGitHubAppToken +) + +var supportedGitHubPrefixes = map[string]TokenType{ + "ghp_": TokenTypePersonalAccessToken, // Personal access token (classic) + "github_pat_": TokenTypeFineGrainedPersonalAccessToken, // Fine-grained personal access token + "gho_": TokenTypeOAuthAccessToken, // OAuth access token + "ghu_": TokenTypeUserToServerGitHubAppToken, // User access token for a GitHub App + "ghs_": TokenTypeServerToServerGitHubAppToken, // Installation access token for a GitHub App (a.k.a. server-to-server token) +} + +var ( + ErrMissingAuthorizationHeader = fmt.Errorf("%w: missing required Authorization header", mark.ErrBadRequest) + ErrBadAuthorizationHeader = fmt.Errorf("%w: Authorization header is badly formatted", mark.ErrBadRequest) + ErrUnsupportedAuthorizationHeader = fmt.Errorf("%w: unsupported Authorization header", mark.ErrBadRequest) +) + +// oldPatternRegexp is the regular expression for the old pattern of the token. +// Until 2021, GitHub API tokens did not have an identifiable prefix. They +// were 40 characters long and only contained the characters a-f and 0-9. +var oldPatternRegexp = regexp.MustCompile(`\A[a-f0-9]{40}\z`) + +// ParseAuthorizationHeader parses the Authorization header from the HTTP request +func ParseAuthorizationHeader(req *http.Request) (tokenType TokenType, token string, _ error) { + authHeader := req.Header.Get(httpheaders.AuthorizationHeader) + if authHeader == "" { + return 0, "", ErrMissingAuthorizationHeader + } + + switch { + // decrypt dotcom token and set it as token + case strings.HasPrefix(authHeader, "GitHub-Bearer "): + return 0, "", ErrUnsupportedAuthorizationHeader + default: + // support both "Bearer" and "bearer" to conform to api.github.com + if len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "Bearer ") { + token = authHeader[7:] + } else { + token = authHeader + } + } + + for prefix, tokenType := range supportedGitHubPrefixes { + if strings.HasPrefix(token, prefix) { + return tokenType, token, nil + } + } + + matchesOldTokenPattern := oldPatternRegexp.MatchString(token) + if matchesOldTokenPattern { + return TokenTypePersonalAccessToken, token, nil + } + + return 0, "", ErrBadAuthorizationHeader +} diff --git a/script/build-ui b/script/build-ui new file mode 100755 index 0000000000..a68f6764ec --- /dev/null +++ b/script/build-ui @@ -0,0 +1,17 @@ +#!/bin/bash +# Build the MCP App UIs +set -e + +cd "$(dirname "$0")/../ui" + +# Install dependencies if needed +if [ ! -d "node_modules" ]; then + echo "Installing UI dependencies..." + npm install +fi + +echo "Building UI..." +npm run build + +echo "UI build complete. Output:" +ls -la ../pkg/github/ui_dist/*.html 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/lint b/script/lint index 47dd537eaf..5b69cbe2ff 100755 --- a/script/lint +++ b/script/lint @@ -5,10 +5,11 @@ gofmt -s -w . BINDIR="$(git rev-parse --show-toplevel)"/bin BINARY=$BINDIR/golangci-lint -GOLANGCI_LINT_VERSION=v2.5.0 +# sync with .github/workflows/lint.yml +GOLANGCI_LINT_VERSION=v2.9.0 if [ ! -f "$BINARY" ]; then - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s "$GOLANGCI_LINT_VERSION" + curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b "$BINDIR" "$GOLANGCI_LINT_VERSION" fi $BINARY run \ No newline at end of file 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 8217c7707b..5f56c1c89b 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -15,22 +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-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE)) - - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE)) + - [github.com/go-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/v79/github](https://pkg.go.dev/github.com/google/go-github/v79/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v79.0.0/LICENSE)) - - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) - - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) + - [github.com/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.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/v1.9.2/LICENSE)) - - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md)) + - [github.com/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) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.2.0/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.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)) @@ -41,12 +41,10 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE)) - [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)) - - [github.com/yudai/golcs](https://pkg.go.dev/github.com/yudai/golcs) ([MIT](https://github.com/yudai/golcs/blob/ecda9a501e82/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](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/8a7402ab: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/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) + - [golang.org/x/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.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index 981e388e52..7d8213d2f2 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -15,22 +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-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE)) - - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE)) + - [github.com/go-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/v79/github](https://pkg.go.dev/github.com/google/go-github/v79/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v79.0.0/LICENSE)) - - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) - - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) + - [github.com/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.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/v1.9.2/LICENSE)) - - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md)) + - [github.com/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) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.2.0/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.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)) @@ -41,12 +41,10 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE)) - [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)) - - [github.com/yudai/golcs](https://pkg.go.dev/github.com/yudai/golcs) ([MIT](https://github.com/yudai/golcs/blob/ecda9a501e82/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](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/8a7402ab: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/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) + - [golang.org/x/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.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index ae0e2389ef..3d0fd8f386 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -15,23 +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-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE)) - - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE)) + - [github.com/go-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/v79/github](https://pkg.go.dev/github.com/google/go-github/v79/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v79.0.0/LICENSE)) - - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) - - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) + - [github.com/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.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/v1.9.2/LICENSE)) - - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md)) + - [github.com/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) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.2.0/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.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)) @@ -42,12 +42,10 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE)) - [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)) - - [github.com/yudai/golcs](https://pkg.go.dev/github.com/yudai/golcs) ([MIT](https://github.com/yudai/golcs/blob/ecda9a501e82/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](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/8a7402ab: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/windows](https://pkg.go.dev/golang.org/x/sys/windows) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) + - [golang.org/x/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.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party/github.com/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-chi/chi/v5/LICENSE b/third-party/github.com/go-chi/chi/v5/LICENSE new file mode 100644 index 0000000000..d99f02ffac --- /dev/null +++ b/third-party/github.com/go-chi/chi/v5/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2015-present Peter Kieltyka (https://github.com/pkieltyka), Google Inc. + +MIT License + +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/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/v79/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/v79/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/github.com/modelcontextprotocol/go-sdk/LICENSE b/third-party/github.com/modelcontextprotocol/go-sdk/LICENSE index 508be92666..5791499cb0 100644 --- a/third-party/github.com/modelcontextprotocol/go-sdk/LICENSE +++ b/third-party/github.com/modelcontextprotocol/go-sdk/LICENSE @@ -1,6 +1,193 @@ +The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. + +Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. + +No rights beyond those granted by the applicable original license are conveyed for such contributions. + +--- + + 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 + +--- + MIT License -Copyright (c) 2025 Go MCP SDK Authors +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -19,3 +206,11 @@ 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. + +--- + +Creative Commons Attribution 4.0 International (CC-BY-4.0) + +Documentation in this project (excluding specifications) is licensed under +CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for +the full license text. diff --git a/third-party/github.com/yudai/golcs/LICENSE b/third-party/github.com/segmentio/asm/LICENSE similarity index 86% rename from third-party/github.com/yudai/golcs/LICENSE rename to third-party/github.com/segmentio/asm/LICENSE index ab7d2e0fba..29e1ab6b05 100644 --- a/third-party/github.com/yudai/golcs/LICENSE +++ b/third-party/github.com/segmentio/asm/LICENSE @@ -1,6 +1,6 @@ -The MIT License (MIT) +MIT License -Copyright (c) 2015 Iwasaki Yudai +Copyright (c) 2021 Segment Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -9,13 +9,13 @@ 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 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. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/segmentio/encoding/LICENSE b/third-party/github.com/segmentio/encoding/LICENSE new file mode 100644 index 0000000000..1fbffdf72a --- /dev/null +++ b/third-party/github.com/segmentio/encoding/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Segment.io, Inc. + +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/LICENSE b/third-party/golang.org/x/oauth2/LICENSE similarity index 100% rename from third-party/golang.org/x/exp/LICENSE rename to third-party/golang.org/x/oauth2/LICENSE diff --git a/third-party/golang.org/x/sys/unix/LICENSE b/third-party/golang.org/x/sys/LICENSE similarity index 100% rename from third-party/golang.org/x/sys/unix/LICENSE rename to third-party/golang.org/x/sys/LICENSE diff --git a/third-party/golang.org/x/sys/windows/LICENSE b/third-party/golang.org/x/sys/windows/LICENSE deleted file mode 100644 index 2a7cf70da6..0000000000 --- a/third-party/golang.org/x/sys/windows/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright 2009 The Go Authors. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google LLC nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/gopkg.in/yaml.v2/LICENSE b/third-party/gopkg.in/yaml.v2/LICENSE deleted file mode 100644 index 8dada3edaf..0000000000 --- a/third-party/gopkg.in/yaml.v2/LICENSE +++ /dev/null @@ -1,201 +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/gopkg.in/yaml.v2/NOTICE b/third-party/gopkg.in/yaml.v2/NOTICE deleted file mode 100644 index 866d74a7ad..0000000000 --- a/third-party/gopkg.in/yaml.v2/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 new file mode 100644 index 0000000000..f8ebc8aedb --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,5914 @@ +{ + "name": "@github/mcp-server-ui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@github/mcp-server-ui", + "version": "1.0.0", + "dependencies": { + "@github/markdown-toolbar-element": "^2.2.3", + "@modelcontextprotocol/ext-apps": "^1.7.2", + "@primer/octicons-react": "^19.0.0", + "@primer/react": "^36.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@types/node": "^25.2.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "@vitejs/plugin-react": "^6.0.2", + "typescript": "^5.7.0", + "vite": "^8.0.16", + "vite-plugin-singlefile": "^2.3.3" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "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", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "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", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@emotion/stylis": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz", + "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT", + "peer": true + }, + "node_modules/@github/combobox-nav": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.3.1.tgz", + "integrity": "sha512-gwxPzLw8XKecy1nP63i9lOBritS3bWmxl02UX6G0TwMQZbMem1BCS1tEZgYd3mkrkiDrUMWaX+DbFCuDFo3K+A==", + "license": "MIT" + }, + "node_modules/@github/markdown-toolbar-element": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@github/markdown-toolbar-element/-/markdown-toolbar-element-2.2.3.tgz", + "integrity": "sha512-AlquKGee+IWiAMYVB0xyHFZRMnu4n3X4HTvJHu79GiVJ1ojTukCWyxMlF5NMsecoLcBKsuBhx3QPv2vkE/zQ0A==", + "license": "MIT" + }, + "node_modules/@github/paste-markdown": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@github/paste-markdown/-/paste-markdown-1.5.3.tgz", + "integrity": "sha512-PzZ1b3PaqBzYqbT4fwKEhiORf38h2OcGp2+JdXNNM7inZ7egaSmfmhyNkQILpqWfS0AYtRS3CDq6z03eZ8yOMQ==", + "license": "MIT" + }, + "node_modules/@github/relative-time-element": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@github/relative-time-element/-/relative-time-element-4.5.1.tgz", + "integrity": "sha512-uxCxCwe9vdwUDmRmM84tN0UERlj8MosLV44+r/VDj7DZUVUSTP4vyWlE9mRK6vHelOmT8DS3RMlaMrLlg1h1PQ==", + "license": "MIT" + }, + "node_modules/@github/tab-container-element": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/@github/tab-container-element/-/tab-container-element-4.8.2.tgz", + "integrity": "sha512-WkaM4mfs8x7dXRWEaDb5deC0OhH6sGQ5cw8i/sVw25gikl4f8C7mHj0kihL5k3eKIIqmGT1Fdswdoi+9ZLDpRA==", + "license": "MIT" + }, + "node_modules/@hono/node-server": { + "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": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "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" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "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" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "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" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "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" + } + }, + "node_modules/@lit-labs/react": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lit-labs/react/-/react-1.2.1.tgz", + "integrity": "sha512-DiZdJYFU0tBbdQkfwwRSwYyI/mcWkg3sWesKRsHUd4G+NekTmmeq9fzsurvcKTNVa0comNljwtg4Hvi1ds3V+A==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz", + "integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==", + "license": "BSD-3-Clause" + }, + "node_modules/@modelcontextprotocol/ext-apps": { + "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/*" + ], + "dependencies": { + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@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" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "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/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "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/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@primer/behaviors": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.10.1.tgz", + "integrity": "sha512-9iNr3ulh2W4zmp1e2COu3XBNjq/eqXbHkCvg2SMD/g8zSe7oBXa/FFg8gdaXmyykElfWRytvZkaJh14FrY22Gw==", + "license": "MIT" + }, + "node_modules/@primer/live-region-element": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@primer/live-region-element/-/live-region-element-0.7.2.tgz", + "integrity": "sha512-wdxCHfcJzE1IPPjZNFR4RTwRcSWb7TN0fRdMH5HcxphLEnuZBWy0TAxk3xPA+/6lwiN3uEJ+ZWV4UF/glXh43A==", + "license": "MIT", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.2.0" + } + }, + "node_modules/@primer/octicons-react": { + "version": "19.21.2", + "resolved": "https://registry.npmjs.org/@primer/octicons-react/-/octicons-react-19.21.2.tgz", + "integrity": "sha512-Bk+S08EpeeWLFscUxwEY8t5z14KxByhIbPG6OiYXSNrkbzN4fmRetnB/C+K1srn4BWuRSwwFxUwvDI2ytgNrFw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.3" + } + }, + "node_modules/@primer/primitives": { + "version": "7.17.1", + "resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-7.17.1.tgz", + "integrity": "sha512-SiPzEb+up1nDpV2NGwNiY8m6sGnF3OUqRb0has5s6T40vq6Li/g3cYVgl+oolEa4DUoNygEPs09jwJt24f/3zg==", + "license": "MIT" + }, + "node_modules/@primer/react": { + "version": "36.27.0", + "resolved": "https://registry.npmjs.org/@primer/react/-/react-36.27.0.tgz", + "integrity": "sha512-dVyp0f9zbbQYQZ6ztfMET43vVaWhvSz+qWirBzpRjDxvCk8vCQsvWrVGUU/PR0kAxxDHf6hqeLG7vcDL229NLA==", + "license": "MIT", + "dependencies": { + "@github/combobox-nav": "^2.1.5", + "@github/markdown-toolbar-element": "^2.1.0", + "@github/paste-markdown": "^1.4.0", + "@github/relative-time-element": "^4.4.1", + "@github/tab-container-element": "^4.8.0", + "@lit-labs/react": "1.2.1", + "@oddbird/popover-polyfill": "^0.3.1", + "@primer/behaviors": "^1.7.0", + "@primer/live-region-element": "^0.7.0", + "@primer/octicons-react": "^19.9.0", + "@primer/primitives": "^7.16.0", + "@styled-system/css": "^5.1.5", + "@styled-system/props": "^5.1.5", + "@styled-system/theme-get": "^5.1.2", + "@types/react-is": "^18.2.1", + "@types/styled-system": "^5.1.12", + "@types/styled-system__css": "^5.0.16", + "@types/styled-system__theme-get": "^5.0.1", + "clsx": "^1.2.1", + "color2k": "^2.0.3", + "deepmerge": "^4.2.2", + "focus-visible": "^5.2.0", + "fzy.js": "^0.4.1", + "history": "^5.0.0", + "lodash.isempty": "^4.4.0", + "lodash.isobject": "^3.0.2", + "react-intersection-observer": "^9.4.3", + "react-is": "^18.2.0", + "react-markdown": "8.0.7", + "styled-system": "^5.1.5" + }, + "engines": { + "node": ">=12", + "npm": ">=7" + }, + "peerDependencies": { + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "@types/styled-components": "^5.1.11", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "styled-components": "5.x" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, + "@types/styled-components": { + "optional": true + } + } + }, + "node_modules/@primer/react/node_modules/@types/hast": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", + "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/@primer/react/node_modules/@types/mdast": { + "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", + "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/@primer/react/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/@primer/react/node_modules/hast-util-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-2.0.1.tgz", + "integrity": "sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/inline-style-parser": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", + "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==", + "license": "MIT" + }, + "node_modules/@primer/react/node_modules/mdast-util-from-markdown": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz", + "integrity": "sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "mdast-util-to-string": "^3.1.0", + "micromark": "^3.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-decode-string": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "unist-util-stringify-position": "^3.0.0", + "uvu": "^0.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/mdast-util-to-hast": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-12.3.0.tgz", + "integrity": "sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "@types/mdast": "^3.0.0", + "mdast-util-definitions": "^5.0.0", + "micromark-util-sanitize-uri": "^1.1.0", + "trim-lines": "^3.0.0", + "unist-util-generated": "^2.0.0", + "unist-util-position": "^4.0.0", + "unist-util-visit": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/mdast-util-to-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz", + "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/micromark": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-3.2.0.tgz", + "integrity": "sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "micromark-core-commonmark": "^1.0.1", + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-chunked": "^1.0.0", + "micromark-util-combine-extensions": "^1.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-encode": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-sanitize-uri": "^1.0.0", + "micromark-util-subtokenize": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.1", + "uvu": "^0.5.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-core-commonmark": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz", + "integrity": "sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-factory-destination": "^1.0.0", + "micromark-factory-label": "^1.0.0", + "micromark-factory-space": "^1.0.0", + "micromark-factory-title": "^1.0.0", + "micromark-factory-whitespace": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-chunked": "^1.0.0", + "micromark-util-classify-character": "^1.0.0", + "micromark-util-html-tag-name": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-subtokenize": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.1", + "uvu": "^0.5.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-factory-destination": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz", + "integrity": "sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-factory-label": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz", + "integrity": "sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-factory-space": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", + "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-factory-title": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz", + "integrity": "sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-factory-whitespace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz", + "integrity": "sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-util-character": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", + "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-util-chunked": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz", + "integrity": "sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-util-classify-character": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz", + "integrity": "sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-util-combine-extensions": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz", + "integrity": "sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-util-decode-numeric-character-reference": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz", + "integrity": "sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-util-decode-string": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz", + "integrity": "sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-util-encode": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz", + "integrity": "sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/@primer/react/node_modules/micromark-util-html-tag-name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz", + "integrity": "sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/@primer/react/node_modules/micromark-util-normalize-identifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz", + "integrity": "sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-util-resolve-all": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz", + "integrity": "sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-util-sanitize-uri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz", + "integrity": "sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-encode": "^1.0.0", + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-util-subtokenize": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz", + "integrity": "sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" + } + }, + "node_modules/@primer/react/node_modules/micromark-util-symbol": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", + "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/@primer/react/node_modules/micromark-util-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", + "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/@primer/react/node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@primer/react/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/@primer/react/node_modules/react-markdown": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-8.0.7.tgz", + "integrity": "sha512-bvWbzG4MtOU62XqBx3Xx+zB2raaFFsq4mYiAzfjXJMEz2sixgeAfraA3tvzULF02ZdOMUOKTBFFaZJDDrq+BJQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "@types/prop-types": "^15.0.0", + "@types/unist": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^2.0.0", + "prop-types": "^15.0.0", + "property-information": "^6.0.0", + "react-is": "^18.0.0", + "remark-parse": "^10.0.0", + "remark-rehype": "^10.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-object": "^0.4.0", + "unified": "^10.0.0", + "unist-util-visit": "^4.0.0", + "vfile": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, + "node_modules/@primer/react/node_modules/remark-parse": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-10.0.2.tgz", + "integrity": "sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-from-markdown": "^1.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/remark-rehype": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-10.1.0.tgz", + "integrity": "sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "@types/mdast": "^3.0.0", + "mdast-util-to-hast": "^12.1.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/style-to-object": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.4.tgz", + "integrity": "sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.1.1" + } + }, + "node_modules/@primer/react/node_modules/unified": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", + "integrity": "sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "bail": "^2.0.0", + "extend": "^3.0.0", + "is-buffer": "^2.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/unist-util-is": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", + "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "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==", + "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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "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.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "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/@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", + "resolved": "https://registry.npmjs.org/@styled-system/background/-/background-5.1.2.tgz", + "integrity": "sha512-jtwH2C/U6ssuGSvwTN3ri/IyjdHb8W9X/g8Y0JLcrH02G+BW3OS8kZdHphF1/YyRklnrKrBT2ngwGUK6aqqV3A==", + "license": "MIT", + "dependencies": { + "@styled-system/core": "^5.1.2" + } + }, + "node_modules/@styled-system/border": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@styled-system/border/-/border-5.1.5.tgz", + "integrity": "sha512-JvddhNrnhGigtzWRCVuAHepniyVi6hBlimxWDVAdcTuk7aRn9BYJUwfHslURtwYFsF5FoEs8Zmr1oZq2M1AP0A==", + "license": "MIT", + "dependencies": { + "@styled-system/core": "^5.1.2" + } + }, + "node_modules/@styled-system/color": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@styled-system/color/-/color-5.1.2.tgz", + "integrity": "sha512-1kCkeKDZkt4GYkuFNKc7vJQMcOmTl3bJY3YBUs7fCNM6mMYJeT1pViQ2LwBSBJytj3AB0o4IdLBoepgSgGl5MA==", + "license": "MIT", + "dependencies": { + "@styled-system/core": "^5.1.2" + } + }, + "node_modules/@styled-system/core": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@styled-system/core/-/core-5.1.2.tgz", + "integrity": "sha512-XclBDdNIy7OPOsN4HBsawG2eiWfCcuFt6gxKn1x4QfMIgeO6TOlA2pZZ5GWZtIhCUqEPTgIBta6JXsGyCkLBYw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1" + } + }, + "node_modules/@styled-system/css": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@styled-system/css/-/css-5.1.5.tgz", + "integrity": "sha512-XkORZdS5kypzcBotAMPBoeckDs9aSZVkvrAlq5K3xP8IMAUek+x2O4NtwoSgkYkWWzVBu6DGdFZLR790QWGG+A==", + "license": "MIT" + }, + "node_modules/@styled-system/flexbox": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@styled-system/flexbox/-/flexbox-5.1.2.tgz", + "integrity": "sha512-6hHV52+eUk654Y1J2v77B8iLeBNtc+SA3R4necsu2VVinSD7+XY5PCCEzBFaWs42dtOEDIa2lMrgL0YBC01mDQ==", + "license": "MIT", + "dependencies": { + "@styled-system/core": "^5.1.2" + } + }, + "node_modules/@styled-system/grid": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@styled-system/grid/-/grid-5.1.2.tgz", + "integrity": "sha512-K3YiV1KyHHzgdNuNlaw8oW2ktMuGga99o1e/NAfTEi5Zsa7JXxzwEnVSDSBdJC+z6R8WYTCYRQC6bkVFcvdTeg==", + "license": "MIT", + "dependencies": { + "@styled-system/core": "^5.1.2" + } + }, + "node_modules/@styled-system/layout": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@styled-system/layout/-/layout-5.1.2.tgz", + "integrity": "sha512-wUhkMBqSeacPFhoE9S6UF3fsMEKFv91gF4AdDWp0Aym1yeMPpqz9l9qS/6vjSsDPF7zOb5cOKC3tcKKOMuDCPw==", + "license": "MIT", + "dependencies": { + "@styled-system/core": "^5.1.2" + } + }, + "node_modules/@styled-system/position": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@styled-system/position/-/position-5.1.2.tgz", + "integrity": "sha512-60IZfMXEOOZe3l1mCu6sj/2NAyUmES2kR9Kzp7s2D3P4qKsZWxD1Se1+wJvevb+1TP+ZMkGPEYYXRyU8M1aF5A==", + "license": "MIT", + "dependencies": { + "@styled-system/core": "^5.1.2" + } + }, + "node_modules/@styled-system/props": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@styled-system/props/-/props-5.1.5.tgz", + "integrity": "sha512-FXhbzq2KueZpGaHxaDm8dowIEWqIMcgsKs6tBl6Y6S0njG9vC8dBMI6WSLDnzMoSqIX3nSKHmOmpzpoihdDewg==", + "license": "MIT", + "dependencies": { + "styled-system": "^5.1.5" + } + }, + "node_modules/@styled-system/shadow": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@styled-system/shadow/-/shadow-5.1.2.tgz", + "integrity": "sha512-wqniqYb7XuZM7K7C0d1Euxc4eGtqEe/lvM0WjuAFsQVImiq6KGT7s7is+0bNI8O4Dwg27jyu4Lfqo/oIQXNzAg==", + "license": "MIT", + "dependencies": { + "@styled-system/core": "^5.1.2" + } + }, + "node_modules/@styled-system/space": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@styled-system/space/-/space-5.1.2.tgz", + "integrity": "sha512-+zzYpR8uvfhcAbaPXhH8QgDAV//flxqxSjHiS9cDFQQUSznXMQmxJegbhcdEF7/eNnJgHeIXv1jmny78kipgBA==", + "license": "MIT", + "dependencies": { + "@styled-system/core": "^5.1.2" + } + }, + "node_modules/@styled-system/theme-get": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@styled-system/theme-get/-/theme-get-5.1.2.tgz", + "integrity": "sha512-afAYdRqrKfNIbVgmn/2Qet1HabxmpRnzhFwttbGr6F/mJ4RDS/Cmn+KHwHvNXangQsWw/5TfjpWV+rgcqqIcJQ==", + "license": "MIT", + "dependencies": { + "@styled-system/core": "^5.1.2" + } + }, + "node_modules/@styled-system/typography": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@styled-system/typography/-/typography-5.1.2.tgz", + "integrity": "sha512-BxbVUnN8N7hJ4aaPOd7wEsudeT7CxarR+2hns8XCX1zp0DFfbWw4xYa/olA0oQaqx7F1hzDg+eRaGzAJbF+jOg==", + "license": "MIT", + "dependencies": { + "@styled-system/core": "^5.1.2" + } + }, + "node_modules/@styled-system/variant": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@styled-system/variant/-/variant-5.1.5.tgz", + "integrity": "sha512-Yn8hXAFoWIro8+Q5J8YJd/mP85Teiut3fsGVR9CAxwgNfIAiqlYxsk5iHU7VHJks/0KjL4ATSjmbtCDC/4l1qw==", + "license": "MIT", + "dependencies": { + "@styled-system/core": "^5.1.2", + "@styled-system/css": "^5.1.5" + } + }, + "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": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", + "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-zts4lhQn5ia0cF/y2+3V6Riu0MAfez9/LJYavdM8TvcVl+S91A/7VWxyBT8hbRuWspmuCaiGI0F41OJYGrKhRA==", + "license": "MIT", + "dependencies": { + "@types/react": "^18" + } + }, + "node_modules/@types/styled-system": { + "version": "5.1.25", + "resolved": "https://registry.npmjs.org/@types/styled-system/-/styled-system-5.1.25.tgz", + "integrity": "sha512-B1oyjE4oeAbVnkigcB0WqU2gPFuTwLV/KkLa/uJZWFB9JWVKq1Fs0QwodZXZ9Sq6cb9ngY4kDqRY/dictIchjA==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/styled-system__css": { + "version": "5.0.22", + "resolved": "https://registry.npmjs.org/@types/styled-system__css/-/styled-system__css-5.0.22.tgz", + "integrity": "sha512-1oOWbdcL1SE2t6hTC3LlwrVHK3Z1Py4KYFehl6NL2XcLxS/L0ELEmN6APNWIYqUywPdeaKlQkRpV5dn0trLjGA==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/styled-system__theme-get": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/styled-system__theme-get/-/styled-system__theme-get-5.0.4.tgz", + "integrity": "sha512-dbzwxQ+8x6Bo3EKZMo9M3Knzo77ukwoC/isKW+GAuF5TenXlPkvgzx4t4+Lp0+fKs2M4owSef0KO3gtGW3Hpkw==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "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": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@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": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "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": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/babel-plugin-styled-components": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.1.4.tgz", + "integrity": "sha512-Xgp9g+A/cG47sUyRwwYxGM4bR/jDRg5N6it/8+HxCnbT5XNKSKDT9xm4oag/osgqjC2It/vH0yXsomOG6k558g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-module-imports": "^7.22.5", + "@babel/plugin-syntax-jsx": "^7.22.5", + "lodash": "^4.17.21", + "picomatch": "^2.3.1" + }, + "peerDependencies": { + "styled-components": ">= 2" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0", + "peer": true + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color2k": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/color2k/-/color2k-2.0.3.tgz", + "integrity": "sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==", + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/content-disposition": { + "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": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "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", + "peer": true + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "peer": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "license": "ISC", + "peer": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT", + "peer": true + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "peer": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "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": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "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.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "peer": true + }, + "node_modules/fast-uri": { + "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", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/focus-visible": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/focus-visible/-/focus-visible-5.2.1.tgz", + "integrity": "sha512-8Bx950VD1bWTQJEH/AM6SpEk+SU55aVnp4Ujhuuxy3eMEBCRwBnTBnVXr9YAPvZL3/CNjCa8u4IWfNmEO53whA==", + "license": "W3C" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fzy.js": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fzy.js/-/fzy.js-0.4.1.tgz", + "integrity": "sha512-4sPVXf+9oGhzg2tYzgWe4hgAY0wEbkqeuKVEgdnqX8S8VcLosQsDjb0jV+f5uoQlf8INWId1w0IGoufAoik1TA==", + "license": "MIT" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "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" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "peer": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "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": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/history": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", + "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.6" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT", + "peer": true + }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "peer": true + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/ip-address": { + "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": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT", + "peer": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "peer": true + }, + "node_modules/jose": { + "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": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "peer": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/json5": { + "version": "2.2.3", + "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" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "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.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isempty": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", + "integrity": "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==", + "license": "MIT" + }, + "node_modules/lodash.isobject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-3.0.2.tgz", + "integrity": "sha512-3/Qptq2vr7WeJbB4KHUSKlq8Pl7ASXi3UG6CMbBm8WRtXi8+GHm7mKaU3urfpSEzWe2wCIChs6/sdocUsTKJiA==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "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" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-definitions": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz", + "integrity": "sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "unist-util-visit": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-definitions/node_modules/@types/mdast": { + "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", + "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/mdast-util-definitions/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/mdast-util-definitions/node_modules/unist-util-is": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", + "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-definitions/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/mdast-util-definitions/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/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "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.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": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "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" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT", + "peer": true + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "peer": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "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": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-intersection-observer": { + "version": "9.16.0", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz", + "integrity": "sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.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.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "peer": true + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "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" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC", + "peer": true + }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT", + "peer": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "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" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "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" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "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.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/styled-components": { + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.11.tgz", + "integrity": "sha512-uuzIIfnVkagcVHv9nE0VPlHPSCmXIUGKfJ42LNjxCCTDTL5sgnJ8Z7GZBq0EnLYGln77tPpEpExt2+qa+cZqSw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/traverse": "^7.4.5", + "@emotion/is-prop-valid": "^1.1.0", + "@emotion/stylis": "^0.8.4", + "@emotion/unitless": "^0.7.4", + "babel-plugin-styled-components": ">= 1.12.0", + "css-to-react-native": "^3.0.0", + "hoist-non-react-statics": "^3.0.0", + "shallowequal": "^1.1.0", + "supports-color": "^5.5.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0", + "react-is": ">= 16.8.0" + } + }, + "node_modules/styled-system": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/styled-system/-/styled-system-5.1.5.tgz", + "integrity": "sha512-7VoD0o2R3RKzOzPK0jYrVnS8iJdfkKsQJNiLRDjikOpQVqQHns/DXWaPZOH4tIKkhAT7I6wIsy9FWTWh2X3q+A==", + "license": "MIT", + "dependencies": { + "@styled-system/background": "^5.1.2", + "@styled-system/border": "^5.1.5", + "@styled-system/color": "^5.1.2", + "@styled-system/core": "^5.1.2", + "@styled-system/flexbox": "^5.1.2", + "@styled-system/grid": "^5.1.2", + "@styled-system/layout": "^5.1.2", + "@styled-system/position": "^5.1.2", + "@styled-system/shadow": "^5.1.2", + "@styled-system/space": "^5.1.2", + "@styled-system/typography": "^5.1.2", + "@styled-system/variant": "^5.1.5", + "object-assign": "^4.1.1" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/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" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "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": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "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.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": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "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": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-generated": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-2.0.1.tgz", + "integrity": "sha512-qF72kLmPxAw0oN2fwpWIqbXAVyEqUzDHMsbtPvOudIlUzXYFIeQIuxXQCRCFh22B7cixvU0MG7m3MW8FTq/S+A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uvu": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", + "integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0", + "diff": "^5.0.0", + "kleur": "^4.0.3", + "sade": "^1.7.3" + }, + "bin": { + "uvu": "bin.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@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": "^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" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-singlefile": { + "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": { + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">18.0.0" + }, + "peerDependencies": { + "rollup": "^4.59.0", + "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "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": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "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" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "peer": true + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC", + "peer": true + }, + "node_modules/zod": { + "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": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "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.28 || ^4" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000000..9644b72d9e --- /dev/null +++ b/ui/package.json @@ -0,0 +1,35 @@ +{ + "name": "@github/mcp-server-ui", + "version": "1.0.0", + "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": "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.7.2", + "@primer/octicons-react": "^19.0.0", + "@primer/react": "^36.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@types/node": "^25.2.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "@vitejs/plugin-react": "^6.0.2", + "typescript": "^5.7.0", + "vite": "^8.0.16", + "vite-plugin-singlefile": "^2.3.3" + } +} diff --git a/ui/scripts/build.mjs b/ui/scripts/build.mjs new file mode 100644 index 0000000000..9efa58524c --- /dev/null +++ b/ui/scripts/build.mjs @@ -0,0 +1,14 @@ +// Build all UI apps in a single Node process. +// +// Replaces serial `cross-env APP= vite build` invocations: doing it +// in one process avoids paying Vite/plugin startup cost for each app and is +// portable without `cross-env`. + +import { build } from "vite"; + +const apps = ["get-me", "issue-write", "pr-write", "pr-edit"]; + +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 new file mode 100644 index 0000000000..c181fcab90 --- /dev/null +++ b/ui/src/apps/get-me/App.tsx @@ -0,0 +1,197 @@ +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 { + OrganizationIcon, + LocationIcon, + LinkIcon, + MailIcon, + PeopleIcon, + RepoIcon, + PersonIcon, +} from "@primer/octicons-react"; +import { AppProvider } from "../../components/AppProvider"; +import { useMcpApp } from "../../hooks/useMcpApp"; + +interface UserData { + login: string; + avatar_url?: string; + details?: { + name?: string; + company?: string; + location?: string; + blog?: string; + email?: string; + twitter_username?: string; + public_repos?: number; + followers?: number; + following?: number; + }; +} + +function AvatarWithFallback({ src, login, size }: { src?: string; login: string; size: number }) { + const [imgError, setImgError] = useState(false); + + if (!src || imgError) { + return ( + + + + ); + } + + return ( + setImgError(true)} + /> + ); +} + +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 ( + + {/* Header with avatar and name */} + + + + + {d.name || user.login} + + @{user.login} + + + + {/* Info grid */} + + {d.company && ( + <> + + {d.company} + + )} + {d.location && ( + <> + + {d.location} + + )} + {d.blog && ( + <> + + + {d.blog} + + + )} + {d.email && ( + <> + + {d.email} + + )} + + + {/* Stats */} + + + + {d.public_repos ?? 0} + + Repos + + + + {d.followers ?? 0} + + Followers + + + + {d.following ?? 0} + + Following + + + + ); +} + +function GetMeApp() { + const { error, toolResult, hostContext, openLink } = useMcpApp({ + appName: "github-mcp-server-get-me", + }); + + 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; + } + })(); + + return {content}; +} + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/ui/src/apps/get-me/index.html b/ui/src/apps/get-me/index.html new file mode 100644 index 0000000000..dee7373d0c --- /dev/null +++ b/ui/src/apps/get-me/index.html @@ -0,0 +1,13 @@ + + + + + + + GitHub User Profile + + +
+ + + diff --git a/ui/src/apps/issue-write/App.tsx b/ui/src/apps/issue-write/App.tsx new file mode 100644 index 0000000000..6372e2d503 --- /dev/null +++ b/ui/src/apps/issue-write/App.tsx @@ -0,0 +1,1722 @@ +import { StrictMode, useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { createRoot } from "react-dom/client"; +import { + Box, + Text, + TextInput, + Button, + Flash, + Spinner, + FormControl, + CounterLabel, + ActionMenu, + ActionList, + Label, +} from "@primer/react"; +import { + IssueOpenedIcon, + CheckCircleIcon, + TagIcon, + PersonIcon, + RepoIcon, + MilestoneIcon, + LockIcon, +} from "@primer/octicons-react"; +import { AppProvider } from "../../components/AppProvider"; +import { useMcpApp } from "../../hooks/useMcpApp"; +import { MarkdownEditor } from "../../components/MarkdownEditor"; + +interface IssueResult { + ID?: string; + number?: number; + title?: string; + body?: string; + url?: string; + html_url?: string; + URL?: string; +} + +interface LabelItem { + id: string; + text: string; + color: string; +} + +interface AssigneeItem { + id: string; + text: string; +} + +interface MilestoneItem { + id: string; + number: number; + text: string; + description: string; +} + +interface IssueTypeItem { + id: string; + text: string; +} + +type IssueState = "open" | "closed"; +type StateReason = "completed" | "not_planned" | "duplicate"; +type IssueFieldPrimitive = string | number | boolean; + +interface IssueFieldOption { + id: string; + name: string; + description: string; + color: string; +} + +interface IssueFieldItem { + id: string; + name: string; + data_type: string; + description: string; + options: IssueFieldOption[]; +} + +interface IssueFieldValue { + value?: IssueFieldPrimitive; + optionName?: string; + cleared?: boolean; +} + +interface IssueFieldSubmission { + field_name: string; + value?: IssueFieldPrimitive; + field_option_name?: string; + delete?: boolean; +} + +interface RepositoryItem { + id: string; + owner: string; + name: string; + fullName: string; + isPrivate: boolean; +} + +// Calculate text color based on background luminance +function getContrastColor(hexColor: string): string { + const r = parseInt(hexColor.substring(0, 2), 16); + const g = parseInt(hexColor.substring(2, 4), 16); + const b = parseInt(hexColor.substring(4, 6), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5 ? "#000000" : "#ffffff"; +} + +const stateReasonOptions: Array<{ value: StateReason; label: string; description: string }> = [ + { value: "completed", label: "Completed", description: "The work is done" }, + { value: "not_planned", label: "Not planned", description: "The issue won't be worked on" }, + { value: "duplicate", label: "Duplicate", description: "Another issue tracks this" }, +]; + +function normalizeSwatchColor(color: string): string { + const trimmed = color.trim(); + if (!trimmed) return "var(--borderColor-default, var(--color-border-default))"; + if (/^#?[0-9a-fA-F]{6}$/.test(trimmed)) { + return trimmed.startsWith("#") ? trimmed : `#${trimmed}`; + } + return trimmed.toLowerCase(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + if (typeof value === "string" && value.trim()) return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return undefined; +} + +function parseIssueState(value: unknown): IssueState | null { + return value === "open" || value === "closed" ? value : null; +} + +function parseStateReason(value: unknown): StateReason | null { + return value === "completed" || value === "not_planned" || value === "duplicate" ? value : null; +} + +function normalizeRawIssueFieldValue( + field: IssueFieldItem | undefined, + rawValue: unknown +): IssueFieldValue | null { + if (rawValue === null || rawValue === undefined) return null; + + if (isRecord(rawValue)) { + const optionName = + stringValue(rawValue.optionName) || + stringValue(rawValue.field_option_name) || + stringValue(rawValue.name); + if (field?.data_type === "single_select" && optionName) { + return { optionName }; + } + return normalizeRawIssueFieldValue( + field, + rawValue.value ?? rawValue.text ?? rawValue.number ?? rawValue.date ?? rawValue.name + ); + } + + if (field?.data_type === "single_select") { + const optionName = stringValue(rawValue); + return optionName ? { optionName } : null; + } + + if ( + typeof rawValue === "string" || + typeof rawValue === "number" || + typeof rawValue === "boolean" + ) { + return { value: rawValue }; + } + + return null; +} + +function parseStringIssueFieldValue( + entry: string, + fieldsByName: Map +): [string, IssueFieldValue] | null { + const match = entry.match(/^([^:=]+)\s*[:=]\s*(.*)$/); + if (!match) return null; + + const fieldName = match[1].trim(); + const field = fieldsByName.get(fieldName); + if (!field) return null; + + const normalized = normalizeRawIssueFieldValue(field, match[2].trim()); + return normalized ? [fieldName, normalized] : null; +} + +function normalizeIssueFieldEntry( + entry: unknown, + fieldsByName: Map +): [string, IssueFieldValue] | null { + if (typeof entry === "string") return parseStringIssueFieldValue(entry, fieldsByName); + if (!isRecord(entry)) return null; + + const fieldRecord = isRecord(entry.field) ? entry.field : undefined; + const entryName = stringValue(entry.name); + const fieldName = + stringValue(entry.field_name) || + stringValue(entry.fieldName) || + (fieldRecord ? stringValue(fieldRecord.name) : undefined) || + entryName; + if (!fieldName) return null; + + const field = fieldsByName.get(fieldName); + if (!field) return null; + + if (entry.delete === true || entry.cleared === true) { + return [fieldName, { cleared: true }]; + } + + const directOptionName = + stringValue(entry.field_option_name) || + stringValue(entry.fieldOptionName) || + stringValue(entry.optionName) || + (field.data_type === "single_select" && entryName && entryName !== fieldName ? entryName : undefined); + if (directOptionName) return [fieldName, { optionName: directOptionName }]; + + const optionRecord = isRecord(entry.option) ? entry.option : undefined; + const optionName = optionRecord ? stringValue(optionRecord.name) : undefined; + if (optionName) return [fieldName, { optionName }]; + + const normalized = normalizeRawIssueFieldValue( + field, + entry.value ?? entry.text ?? entry.number ?? entry.date + ); + return normalized ? [fieldName, normalized] : null; +} + +function normalizeIssueFieldValues( + input: unknown, + fields: IssueFieldItem[] +): Record { + const fieldsByName = new Map(fields.map((field) => [field.name, field])); + const values: Record = {}; + + if (Array.isArray(input)) { + for (const item of input) { + const normalized = normalizeIssueFieldEntry(item, fieldsByName); + if (normalized) values[normalized[0]] = normalized[1]; + } + return values; + } + + if (!isRecord(input)) return values; + + const normalizedEntry = normalizeIssueFieldEntry(input, fieldsByName); + if (normalizedEntry) { + values[normalizedEntry[0]] = normalizedEntry[1]; + return values; + } + + for (const [fieldName, rawValue] of Object.entries(input)) { + const field = fieldsByName.get(fieldName); + if (!field) continue; + + if (isRecord(rawValue)) { + const nested = normalizeIssueFieldEntry({ ...rawValue, field_name: fieldName }, fieldsByName); + if (nested) { + values[fieldName] = nested[1]; + continue; + } + } + + const normalized = normalizeRawIssueFieldValue(field, rawValue); + if (normalized) values[fieldName] = normalized; + } + + return values; +} + +function SuccessView({ + issue, + owner, + repo, + submittedTitle, + submittedLabels, + isUpdate, + openLink, +}: { + issue: IssueResult; + owner: string; + repo: string; + submittedTitle: string; + submittedLabels: LabelItem[]; + isUpdate: boolean; + openLink: (url: string) => Promise; +}) { + const issueUrl = issue.html_url || issue.url || issue.URL || "#"; + + return ( + + + + + + + {isUpdate ? "Issue updated successfully" : "Issue created successfully"} + + + + + + + + + { + // 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", + display: "block", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + color: "var(--fgColor-accent, var(--color-accent-fg))", + textDecoration: "none", + }} + > + {issue.title || submittedTitle} + {issue.number && ( + + #{issue.number} + + )} + + + {owner}/{repo} + + {submittedLabels.length > 0 && ( + + {submittedLabels.map((label) => ( + + ))} + + )} + + + + ); +} + +function CreateIssueApp() { + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [successIssue, setSuccessIssue] = useState(null); + + // Labels state + const [availableLabels, setAvailableLabels] = useState([]); + const [selectedLabels, setSelectedLabels] = useState([]); + const [labelsLoading, setLabelsLoading] = useState(false); + const [labelsFilter, setLabelsFilter] = useState(""); + + // Assignees state + const [availableAssignees, setAvailableAssignees] = useState([]); + const [selectedAssignees, setSelectedAssignees] = useState([]); + const [assigneesLoading, setAssigneesLoading] = useState(false); + const [assigneesFilter, setAssigneesFilter] = useState(""); + + // Milestones state + const [availableMilestones, setAvailableMilestones] = useState([]); + const [selectedMilestone, setSelectedMilestone] = useState(null); + const [milestonesLoading, setMilestonesLoading] = useState(false); + + // Issue types state + const [availableIssueTypes, setAvailableIssueTypes] = useState([]); + const [selectedIssueType, setSelectedIssueType] = useState(null); + const [issueTypesLoading, setIssueTypesLoading] = useState(false); + + // State transition state + const [currentState, setCurrentState] = useState("open"); + const [stateReason, setStateReason] = useState("completed"); + const [duplicateOf, setDuplicateOf] = useState(""); + const [prefilledStateChange, setPrefilledStateChange] = useState(null); + + // Issue fields state + const [availableIssueFields, setAvailableIssueFields] = useState([]); + const [fieldValues, setFieldValues] = useState>({}); + + // Repository state + const [selectedRepo, setSelectedRepo] = useState(null); + const [repoSearchResults, setRepoSearchResults] = useState([]); + const [repoSearchLoading, setRepoSearchLoading] = useState(false); + const [repoFilter, setRepoFilter] = useState(""); + + const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({ + appName: "github-mcp-server-issue-write", + }); + + // Get method and issue_number from toolInput + const method = (toolInput?.method as string) || "create"; + const issueNumber = toolInput?.issue_number as number | undefined; + const isUpdateMode = method === "update" && issueNumber !== undefined; + + // Initialize from toolInput or selected repo + const owner = selectedRepo?.owner || (toolInput?.owner as string) || ""; + const repo = selectedRepo?.name || (toolInput?.repo as string) || ""; + + // Search repositories when filter changes + useEffect(() => { + if (!app || !repoFilter.trim()) { + setRepoSearchResults([]); + return; + } + + const searchRepos = async () => { + setRepoSearchLoading(true); + try { + const result = await callTool("search_repositories", { + query: repoFilter, + perPage: 10, + }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c) => c.type === "text" + ); + if (textContent && textContent.type === "text" && textContent.text) { + const data = JSON.parse(textContent.text); + const repos = (data.repositories || data.items || []).map( + (r: { id?: number; owner?: { login?: string } | string; name?: string; full_name?: string; private?: boolean }) => ({ + id: String(r.id || r.full_name), + owner: + typeof r.owner === "string" + ? r.owner + : r.owner?.login || r.full_name?.split("/")[0] || "", + name: r.name || r.full_name?.split("/")[1] || "", + fullName: r.full_name || "", + isPrivate: r.private || false, + }) + ); + setRepoSearchResults(repos); + } + } + } catch (e) { + console.error("Failed to search repositories:", e); + } finally { + setRepoSearchLoading(false); + } + }; + + const debounce = setTimeout(searchRepos, 300); + return () => clearTimeout(debounce); + }, [app, callTool, repoFilter]); + + // Load labels, assignees, milestones, issue types, and issue fields when owner/repo available + useEffect(() => { + if (!owner || !repo || !app) return; + + const loadLabels = async () => { + setLabelsLoading(true); + try { + const result = await callTool("ui_get", { method: "labels", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c: { type: string }) => c.type === "text" + ); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const labels = (data.labels || []).map( + (l: { name: string; color: string; id: string }) => ({ + id: l.id || l.name, + text: l.name, + color: l.color, + }) + ); + setAvailableLabels(labels); + } + } + } catch (e) { + console.error("Failed to load labels:", e); + } finally { + setLabelsLoading(false); + } + }; + + const loadAssignees = async () => { + setAssigneesLoading(true); + try { + const result = await callTool("ui_get", { method: "assignees", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c: { type: string }) => c.type === "text" + ); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const assignees = (data.assignees || []).map( + (a: { login: string }) => ({ + id: a.login, + text: a.login, + }) + ); + setAvailableAssignees(assignees); + } + } + } catch (e) { + console.error("Failed to load assignees:", e); + } finally { + setAssigneesLoading(false); + } + }; + + const loadMilestones = async () => { + setMilestonesLoading(true); + try { + const result = await callTool("ui_get", { method: "milestones", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c: { type: string }) => c.type === "text" + ); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const milestones = (data.milestones || []).map( + (m: { number: number; title: string; description: string }) => ({ + id: String(m.number), + number: m.number, + text: m.title, + description: m.description || "", + }) + ); + setAvailableMilestones(milestones); + } + } + } catch (e) { + console.error("Failed to load milestones:", e); + } finally { + setMilestonesLoading(false); + } + }; + + const loadIssueTypes = async () => { + setIssueTypesLoading(true); + try { + const result = await callTool("ui_get", { method: "issue_types", owner }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c: { type: string }) => c.type === "text" + ); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + // ui_get returns array directly or wrapped in issue_types/types + const typesArray = Array.isArray(data) ? data : (data.issue_types || data.types || []); + const types = typesArray.map( + (t: { id: number; name: string; description?: string } | string) => { + if (typeof t === "string") { + return { id: t, text: t }; + } + return { id: String(t.id || t.name), text: t.name }; + } + ); + setAvailableIssueTypes(types); + } + } + } catch (e) { + // Issue types may not be available for all repos/orgs + console.debug("Issue types not available:", e); + } finally { + setIssueTypesLoading(false); + } + }; + + const loadIssueFields = async () => { + try { + const result = await callTool("ui_get", { method: "issue_fields", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c: { type: string }) => c.type === "text" + ); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const fields = (data.fields || []) + .map( + (field: { + id?: string; + name?: string; + data_type?: string; + description?: string; + options?: Array<{ id?: string; name?: string; description?: string; color?: string }>; + }) => ({ + id: String(field.id || field.name || ""), + name: field.name || "", + data_type: field.data_type || "text", + description: field.description || "", + options: (field.options || []) + .map((option) => ({ + id: String(option.id || option.name || ""), + name: option.name || "", + description: option.description || "", + color: option.color || "", + })) + .filter((option) => option.name), + }) + ) + .filter((field: IssueFieldItem) => field.name); + setAvailableIssueFields(fields); + } + } + } catch (e) { + console.debug("Issue fields not available:", e); + setAvailableIssueFields([]); + } + }; + + loadLabels(); + loadAssignees(); + loadMilestones(); + loadIssueTypes(); + loadIssueFields(); + }, [owner, repo, app, callTool]); + + // Track which prefill fields have been applied to avoid re-applying after user edits + const prefillApplied = useRef<{ + title: boolean; + body: boolean; + labels: boolean; + assignees: boolean; + milestone: boolean; + type: boolean; + issueFields: boolean; + }>({ + title: false, + body: false, + labels: false, + assignees: false, + milestone: false, + type: false, + issueFields: false, + }); + + // Store existing issue data for matching when available lists load + interface ExistingIssueData { + labels: string[]; + assignees: string[]; + milestoneNumber: number | null; + issueType: string | null; + fieldValues: unknown; + } + const [existingIssueData, setExistingIssueData] = useState(null); + + // Reset all transient form/result state when toolInput changes (new invocation). + // Without this, the SuccessView from a previous submit stays visible and stale + // form values (e.g. body) bleed through because prefill effects use truthy guards + // that won't overwrite with empty values. The repo is re-initialized from the new + // invocation here (rather than in a separate effect) so it isn't wiped by this reset. + useEffect(() => { + prefillApplied.current = { + title: false, + body: false, + labels: false, + assignees: false, + milestone: false, + type: false, + issueFields: false, + }; + setExistingIssueData(null); + setTitle(""); + setBody(""); + setSelectedLabels([]); + setSelectedAssignees([]); + setSelectedMilestone(null); + setSelectedIssueType(null); + setCurrentState("open"); + setStateReason("completed"); + setDuplicateOf(""); + setPrefilledStateChange(null); + setFieldValues({}); + setSuccessIssue(null); + setError(null); + // Clear available metadata (and filters) so prefill effects, which are gated + // on these lists being non-empty, can't match against the previous repo's data + // before the new repo's ui_get calls resolve. + setAvailableLabels([]); + setAvailableAssignees([]); + setAvailableMilestones([]); + setAvailableIssueTypes([]); + setAvailableIssueFields([]); + setLabelsFilter(""); + setAssigneesFilter(""); + if (toolInput?.owner && toolInput?.repo) { + setSelectedRepo({ + id: `${toolInput.owner}/${toolInput.repo}`, + owner: toolInput.owner as string, + name: toolInput.repo as string, + fullName: `${toolInput.owner}/${toolInput.repo}`, + isPrivate: false, + }); + } else { + setSelectedRepo(null); + } + }, [toolInput]); + + // Load existing issue data when in update mode + useEffect(() => { + if (!isUpdateMode || !owner || !repo || !issueNumber || !app || existingIssueData !== null) { + return; + } + + const loadExistingIssue = async () => { + try { + const result = await callTool("issue_read", { + method: "get", + owner, + repo, + issue_number: issueNumber, + }); + + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c) => c.type === "text" + ); + if (textContent && textContent.type === "text" && textContent.text) { + const issueData = JSON.parse(textContent.text); + + const issueState = parseIssueState(issueData.state); + if (issueState) { + setCurrentState(issueState); + } + + // Pre-fill title and body immediately + if (issueData.title && !prefillApplied.current.title) { + setTitle(issueData.title); + prefillApplied.current.title = true; + } + if (issueData.body && !prefillApplied.current.body) { + setBody(issueData.body); + prefillApplied.current.body = true; + } + + // Pre-fill assignees immediately from issue data + const assigneeLogins = (issueData.assignees || []) + .map((a: { login?: string } | string) => typeof a === 'string' ? a : a.login) + .filter(Boolean) as string[]; + if (assigneeLogins.length > 0 && !prefillApplied.current.assignees) { + setSelectedAssignees(assigneeLogins.map(login => ({ id: login, text: login }))); + prefillApplied.current.assignees = true; + } + + // Pre-fill issue type immediately from issue data + const issueTypeName = issueData.type?.name || (typeof issueData.type === 'string' ? issueData.type : null); + if (issueTypeName && !prefillApplied.current.type) { + setSelectedIssueType({ id: issueTypeName, text: issueTypeName }); + prefillApplied.current.type = true; + } + + // Extract data for deferred matching when available lists load (for labels and milestones) + const labelNames = (issueData.labels || []) + .map((l: { name?: string } | string) => typeof l === 'string' ? l : l.name) + .filter(Boolean) as string[]; + + const milestoneNumber = issueData.milestone + ? (typeof issueData.milestone === 'object' ? issueData.milestone.number : issueData.milestone) + : null; + + setExistingIssueData({ + labels: labelNames, + assignees: assigneeLogins, + milestoneNumber, + issueType: issueTypeName, + fieldValues: issueData.field_values || issueData.fieldValues || [], + }); + } + } + } catch (e) { + console.error("Error loading existing issue:", e); + } + }; + + loadExistingIssue(); + }, [isUpdateMode, owner, repo, issueNumber, app, callTool, existingIssueData]); + + // Apply existing labels when available labels load + useEffect(() => { + if (!existingIssueData?.labels.length || !availableLabels.length || prefillApplied.current.labels) return; + const matched = availableLabels.filter((l) => existingIssueData.labels.includes(l.text)); + if (matched.length > 0) { + setSelectedLabels(matched); + prefillApplied.current.labels = true; + } + }, [existingIssueData, availableLabels]); + + // Apply existing milestone when available milestones load + useEffect(() => { + if (!existingIssueData?.milestoneNumber || !availableMilestones.length || prefillApplied.current.milestone) return; + const matched = availableMilestones.find((m) => m.number === existingIssueData.milestoneNumber); + if (matched) { + setSelectedMilestone(matched); + } + prefillApplied.current.milestone = true; + }, [existingIssueData, availableMilestones]); + + // Pre-fill title and body immediately (don't wait for data loading) + useEffect(() => { + if (toolInput?.title && !prefillApplied.current.title) { + setTitle(toolInput.title as string); + prefillApplied.current.title = true; + } + if (toolInput?.body && !prefillApplied.current.body) { + setBody(toolInput.body as string); + prefillApplied.current.body = true; + } + }, [toolInput]); + + // Pre-fill requested state transition controls from tool input + useEffect(() => { + const state = parseIssueState(toolInput?.state); + if (state) { + setPrefilledStateChange(state); + } + + const reason = parseStateReason(toolInput?.state_reason); + if (reason) { + setStateReason(reason); + } + + if (toolInput?.duplicate_of !== undefined && toolInput?.duplicate_of !== null) { + setDuplicateOf(String(toolInput.duplicate_of)); + } + }, [toolInput]); + + // Pre-fill labels once available data is loaded + useEffect(() => { + if ( + toolInput?.labels && + Array.isArray(toolInput.labels) && + availableLabels.length > 0 && + !prefillApplied.current.labels + ) { + const prefillLabels = availableLabels.filter((l) => + (toolInput.labels as string[]).includes(l.text) + ); + if (prefillLabels.length > 0) { + setSelectedLabels(prefillLabels); + prefillApplied.current.labels = true; + } + } + }, [toolInput, availableLabels]); + + // Pre-fill assignees once available data is loaded + useEffect(() => { + if ( + toolInput?.assignees && + Array.isArray(toolInput.assignees) && + availableAssignees.length > 0 && + !prefillApplied.current.assignees + ) { + const prefillAssignees = availableAssignees.filter((a) => + (toolInput.assignees as string[]).includes(a.text) + ); + if (prefillAssignees.length > 0) { + setSelectedAssignees(prefillAssignees); + prefillApplied.current.assignees = true; + } + } + }, [toolInput, availableAssignees]); + + // Pre-fill milestone once available data is loaded + useEffect(() => { + if ( + toolInput?.milestone && + availableMilestones.length > 0 && + !prefillApplied.current.milestone + ) { + const milestone = availableMilestones.find( + (m) => m.number === Number(toolInput.milestone) + ); + if (milestone) { + setSelectedMilestone(milestone); + prefillApplied.current.milestone = true; + } + } + }, [toolInput, availableMilestones]); + + // Pre-fill issue type once available data is loaded + useEffect(() => { + if ( + toolInput?.type && + availableIssueTypes.length > 0 && + !prefillApplied.current.type + ) { + const issueType = availableIssueTypes.find( + (t) => t.text === toolInput.type + ); + if (issueType) { + setSelectedIssueType(issueType); + prefillApplied.current.type = true; + } + } + }, [toolInput, availableIssueTypes]); + + // Pre-fill custom fields once field definitions are loaded + useEffect(() => { + if (!availableIssueFields.length || prefillApplied.current.issueFields) return; + + const toolInputValues = normalizeIssueFieldValues(toolInput?.issue_fields, availableIssueFields); + if (Object.keys(toolInputValues).length > 0) { + setFieldValues(toolInputValues); + prefillApplied.current.issueFields = true; + return; + } + + const existingValues = normalizeIssueFieldValues(existingIssueData?.fieldValues, availableIssueFields); + if (Object.keys(existingValues).length > 0) { + setFieldValues(existingValues); + prefillApplied.current.issueFields = true; + } + }, [toolInput, existingIssueData, availableIssueFields]); + + const issueFieldsByName = useMemo( + () => new Map(availableIssueFields.map((field) => [field.name, field])), + [availableIssueFields] + ); + + const updateIssueFieldValue = useCallback((fieldName: string, value: IssueFieldValue) => { + prefillApplied.current.issueFields = true; + setFieldValues((prev) => ({ ...prev, [fieldName]: value })); + }, []); + + const handleSubmit = useCallback(async (stateChange?: IssueState) => { + if (!title.trim()) { + setError("Title is required"); + return; + } + if (!owner || !repo) { + setError("Repository information not available"); + return; + } + + const requestedState = isUpdateMode ? stateChange || prefilledStateChange : null; + let duplicateIssueNumber: number | undefined; + if (requestedState === "closed" && stateReason === "duplicate") { + duplicateIssueNumber = Number(duplicateOf); + if (!Number.isInteger(duplicateIssueNumber) || duplicateIssueNumber <= 0) { + setError("Duplicate issue number is required"); + return; + } + } + + setIsSubmitting(true); + setError(null); + + try { + const params: Record = { + ...(toolInput as Record | undefined), + method: isUpdateMode ? "update" : "create", + owner, + repo, + title: title.trim(), + body: body.trim(), + _ui_submitted: true + }; + + delete params.state; + delete params.state_reason; + delete params.duplicate_of; + delete params.issue_fields; + + if (isUpdateMode && issueNumber) { + params.issue_number = issueNumber; + } + + if (selectedLabels.length > 0) { + params.labels = selectedLabels.map((l) => l.text); + } + if (selectedAssignees.length > 0) { + params.assignees = selectedAssignees.map((a) => a.text); + } + if (selectedMilestone) { + params.milestone = selectedMilestone.number; + } + if (selectedIssueType) { + params.type = selectedIssueType.text; + } + + if (requestedState) { + params.state = requestedState; + if (requestedState === "closed") { + params.state_reason = stateReason; + if (stateReason === "duplicate" && duplicateIssueNumber !== undefined) { + params.duplicate_of = duplicateIssueNumber; + } + } + } + + const issueFields = Object.entries(fieldValues) + .map(([fieldName, value]): IssueFieldSubmission | null => { + if (value.cleared) return { field_name: fieldName, delete: true }; + if (value.optionName !== undefined) { + return { field_name: fieldName, field_option_name: value.optionName }; + } + if (value.value !== undefined && value.value !== "") { + const field = issueFieldsByName.get(fieldName); + const fieldValue = + field?.data_type === "number" && typeof value.value === "string" + ? Number(value.value) + : value.value; + if (typeof fieldValue === "number" && Number.isNaN(fieldValue)) return null; + return { field_name: fieldName, value: fieldValue }; + } + return null; + }) + .filter((field): field is IssueFieldSubmission => field !== null); + if (issueFields.length > 0) { + params.issue_fields = issueFields; + } + + const result = await callTool("issue_write", params); + + if (result.isError) { + const textContent = result.content?.find( + (c: { type: string }) => c.type === "text" + ); + setError( + (textContent as { text?: string })?.text || "Failed to create issue" + ); + } else { + const textContent = result.content?.find( + (c: { type: string }) => c.type === "text" + ); + if (textContent && "text" in textContent) { + 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 }); + } + } + } + } catch (e) { + setError(`Error: ${e instanceof Error ? e.message : String(e)}`); + } finally { + setIsSubmitting(false); + } + }, [ + title, + body, + owner, + repo, + selectedLabels, + selectedAssignees, + selectedMilestone, + selectedIssueType, + isUpdateMode, + issueNumber, + stateReason, + duplicateOf, + prefilledStateChange, + fieldValues, + issueFieldsByName, + toolInput, + callTool, + setModelContext, + ]); + + // Filtered items for dropdowns + const filteredLabels = useMemo(() => { + if (!labelsFilter) return availableLabels; + const lowerFilter = labelsFilter.toLowerCase(); + return availableLabels.filter((l) => + l.text.toLowerCase().includes(lowerFilter) + ); + }, [availableLabels, labelsFilter]); + + const filteredAssignees = useMemo(() => { + if (!assigneesFilter) return availableAssignees; + const lowerFilter = assigneesFilter.toLowerCase(); + return availableAssignees.filter((a) => + a.text.toLowerCase().includes(lowerFilter) + ); + }, [availableAssignees, assigneesFilter]); + + const selectedStateReason = stateReasonOptions.find((option) => option.value === stateReason) || stateReasonOptions[0]; + + const renderIssueFieldInput = (field: IssueFieldItem) => { + const fieldValue = fieldValues[field.name] || {}; + + if (field.data_type === "single_select") { + const selectedOptionName = fieldValue.cleared ? undefined : fieldValue.optionName; + const selectedOption = field.options.find((option) => option.name === selectedOptionName); + return ( + + + + {selectedOption ? selectedOption.name : "Select option"} + + + + {field.options.length === 0 ? ( + No options available + ) : ( + field.options.map((option) => ( + updateIssueFieldValue(field.name, { optionName: option.name })} + > + + + + {option.name} + + )) + )} + + + + + ); + } + + return ( + updateIssueFieldValue(field.name, { value: e.target.value })} + block + contrast + sx={{ flex: 1 }} + /> + ); + }; + + const body_node = (() => { + if (appError) { + return ( + + Connection error: {appError.message} + + ); + } + + if (!app) { + return ( + + + + ); + } + + if (successIssue) { + return ( + + ); + } + + return ( + + {/* Repository picker */} + + + + span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }} + > + {selectedRepo ? selectedRepo.fullName : "Select repository"} + + + + + setRepoFilter(e.target.value)} + sx={{ width: "100%" }} + size="small" + autoFocus + /> + + + {repoSearchLoading ? ( + + + + ) : repoSearchResults.length > 0 ? ( + repoSearchResults.map((r) => ( + { + setSelectedRepo(r); + setRepoFilter(""); + // Clear metadata when switching repos + setAvailableLabels([]); + setSelectedLabels([]); + setAvailableAssignees([]); + setSelectedAssignees([]); + setAvailableMilestones([]); + setSelectedMilestone(null); + setAvailableIssueTypes([]); + setSelectedIssueType(null); + setAvailableIssueFields([]); + setFieldValues({}); + }} + > + + {r.isPrivate ? : } + + {r.fullName} + + )) + ) : selectedRepo ? ( + setRepoFilter("")} + > + + {selectedRepo.isPrivate ? : } + + {selectedRepo.fullName} + + ) : ( + + + Type to search repositories... + + + )} + + + + + + + {/* Error banner */} + {error && ( + + {error} + + )} + + {/* Title */} + + + Title + + setTitle(e.target.value)} + placeholder="Title" + block + contrast + /> + + + {/* Description */} + + + Description + + + + + {/* Metadata section */} + + {/* Labels dropdown */} + + + Labels + {selectedLabels.length > 0 && ( + {selectedLabels.length} + )} + + + + setLabelsFilter(e.target.value)} + size="small" + block + /> + + + {labelsLoading ? ( + + Loading... + + ) : filteredLabels.length === 0 ? ( + No labels available + ) : ( + filteredLabels.map((label) => ( + l.id === label.id)} + onSelect={() => { + setSelectedLabels((prev) => + prev.some((l) => l.id === label.id) + ? prev.filter((l) => l.id !== label.id) + : [...prev, label] + ); + }} + > + + + + {label.text} + + )) + )} + + + + + {/* Assignees dropdown */} + + + Assignees + {selectedAssignees.length > 0 && ( + {selectedAssignees.length} + )} + + + + setAssigneesFilter(e.target.value)} + size="small" + block + /> + + + {assigneesLoading ? ( + + Loading... + + ) : filteredAssignees.length === 0 ? ( + No assignees available + ) : ( + filteredAssignees.map((assignee) => ( + a.id === assignee.id)} + onSelect={() => { + setSelectedAssignees((prev) => + prev.some((a) => a.id === assignee.id) + ? prev.filter((a) => a.id !== assignee.id) + : [...prev, assignee] + ); + }} + > + {assignee.text} + + )) + )} + + + + + {/* Milestones dropdown */} + + + {selectedMilestone ? selectedMilestone.text : "Milestone"} + + + + {milestonesLoading ? ( + + Loading... + + ) : availableMilestones.length === 0 ? ( + No milestones + ) : ( + <> + {selectedMilestone && ( + setSelectedMilestone(null)} + > + Clear selection + + )} + {availableMilestones.map((milestone) => ( + setSelectedMilestone(milestone)} + > + {milestone.text} + {milestone.description && ( + + {milestone.description} + + )} + + ))} + + )} + + + + + {/* Issue Types dropdown */} + + + {selectedIssueType ? selectedIssueType.text : "Type"} + + + + {issueTypesLoading ? ( + + Loading... + + ) : availableIssueTypes.length === 0 ? ( + No issue types + ) : ( + <> + {selectedIssueType && ( + setSelectedIssueType(null)} + > + Clear selection + + )} + {availableIssueTypes.map((type) => ( + setSelectedIssueType(type)} + > + {type.text} + + ))} + + )} + + + + + + {/* Fields section */} + {availableIssueFields.length > 0 && ( + + + Fields + + + {availableIssueFields.map((field) => { + const fieldValue = fieldValues[field.name]; + const hasFieldValue = + fieldValue && + !fieldValue.cleared && + (fieldValue.optionName !== undefined || + (fieldValue.value !== undefined && fieldValue.value !== "")); + + return ( + + + {field.name} + + {field.description && ( + + {field.description} + + )} + + {renderIssueFieldInput(field)} + {hasFieldValue && ( + + )} + + + ); + })} + + + )} + + {/* Selected labels display */} + {selectedLabels.length > 0 && ( + + {selectedLabels.map((label) => ( + + ))} + + )} + + {/* Selected metadata display */} + {(selectedAssignees.length > 0 || selectedMilestone) && ( + + {selectedAssignees.length > 0 && ( + + Assigned to: {selectedAssignees.map((a) => a.text).join(", ")} + + )} + {selectedMilestone && ( + Milestone: {selectedMilestone.text} + )} + + )} + + {/* State and submit actions */} + + {isUpdateMode && ( + + {currentState === "open" ? ( + <> + + + + + {selectedStateReason.label} + + + + {stateReasonOptions.map((option) => ( + setStateReason(option.value)} + > + {option.label} + {option.description} + + ))} + + + + + {stateReason === "duplicate" && ( + + Duplicate of + setDuplicateOf(e.target.value)} + size="small" + sx={{ width: 140 }} + /> + + )} + + ) : ( + + )} + + )} + + + + + ); + })(); + + return {body_node}; +} + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/ui/src/apps/issue-write/index.html b/ui/src/apps/issue-write/index.html new file mode 100644 index 0000000000..e1e34c391a --- /dev/null +++ b/ui/src/apps/issue-write/index.html @@ -0,0 +1,12 @@ + + + + + + Create GitHub Issue + + +
+ + + diff --git a/ui/src/apps/pr-edit/App.tsx b/ui/src/apps/pr-edit/App.tsx new file mode 100644 index 0000000000..9d6163c503 --- /dev/null +++ b/ui/src/apps/pr-edit/App.tsx @@ -0,0 +1,773 @@ +import { StrictMode, useState, useCallback, useEffect, useMemo } from "react"; +import { createRoot } from "react-dom/client"; +import { + Box, + Text, + TextInput, + Button, + Flash, + Spinner, + FormControl, + ActionMenu, + ActionList, + Checkbox, + ButtonGroup, + CounterLabel, + Label, +} from "@primer/react"; +import { + GitPullRequestIcon, + CheckCircleIcon, + GitBranchIcon, + LockIcon, + PersonIcon, + PeopleIcon, +} from "@primer/octicons-react"; +import { AppProvider } from "../../components/AppProvider"; +import { useMcpApp } from "../../hooks/useMcpApp"; +import { MarkdownEditor } from "../../components/MarkdownEditor"; + +interface PRResult { + ID?: string; + number?: number; + title?: string; + url?: string; + html_url?: string; + URL?: string; +} + +interface BranchItem { + name: string; + protected: boolean; +} + +type ReviewerItem = { kind: "user" | "team"; id: string; text: string; avatar?: string; org?: string }; +type PRState = "open" | "closed"; + +interface InitialPRState { + title: string; + body: string; + state: PRState; + base: string; + draft: boolean; + maintainerCanModify: boolean; + reviewers: string[]; +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null ? (value as Record) : null; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function asNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +function reviewerFromValue(value: string): ReviewerItem { + if (value.includes("/")) { + const [org, slug] = value.split("/", 2); + return { kind: "team", id: `${org}/${slug}`, text: `${org}/${slug}`, org }; + } + return { kind: "user", id: value, text: value }; +} + +function reviewerValue(reviewer: ReviewerItem): string { + return reviewer.kind === "team" ? reviewer.id : reviewer.text; +} + +function sameReviewerValues(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((value, index) => value === sortedB[index]); +} + +function parseUserReviewer(value: unknown): ReviewerItem | null { + if (typeof value === "string") return reviewerFromValue(value); + const record = asRecord(value); + const login = asString(record?.login); + if (!login) return null; + return { kind: "user", id: login, text: login, avatar: asString(record?.avatar_url) }; +} + +function parseTeamReviewer(value: unknown, fallbackOrg: string): ReviewerItem | null { + if (typeof value === "string") { + if (value.includes("/")) return reviewerFromValue(value); + return { kind: "team", id: `${fallbackOrg}/${value}`, text: `${fallbackOrg}/${value}`, org: fallbackOrg }; + } + + const record = asRecord(value); + const slug = asString(record?.slug) || asString(record?.name); + if (!slug) return null; + + const organization = asRecord(record?.organization); + const org = asString(record?.org) || asString(organization?.login) || fallbackOrg; + const id = org ? `${org}/${slug}` : slug; + return { kind: "team", id, text: id, org }; +} + +function reviewersFromValues(values: unknown): ReviewerItem[] | undefined { + if (!Array.isArray(values)) return undefined; + return values + .map((value) => (typeof value === "string" ? reviewerFromValue(value) : null)) + .filter((value): value is ReviewerItem => value !== null); +} + +function extractRequestedReviewers(prData: Record, owner: string): ReviewerItem[] { + const requestedReviewers = Array.isArray(prData.requested_reviewers) ? prData.requested_reviewers : []; + const requestedTeams = Array.isArray(prData.requested_teams) ? prData.requested_teams : []; + return [ + ...requestedReviewers.map(parseUserReviewer).filter((value): value is ReviewerItem => value !== null), + ...requestedTeams.map((team) => parseTeamReviewer(team, owner)).filter((value): value is ReviewerItem => value !== null), + ]; +} + +function parsePRState(value: unknown): PRState { + return value === "closed" ? "closed" : "open"; +} + +function buildInitialState(prData: Record, owner: string): InitialPRState { + const base = asRecord(prData.base); + const requestedReviewers = extractRequestedReviewers(prData, owner); + return { + title: asString(prData.title) || "", + body: asString(prData.body) || "", + state: parsePRState(prData.state), + base: asString(base?.ref) || "", + draft: asBoolean(prData.draft) || false, + maintainerCanModify: asBoolean(prData.maintainer_can_modify) || false, + reviewers: requestedReviewers.map(reviewerValue), + }; +} + +function SuccessView({ + pr, + 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 || "#"; + + return ( + + + + + + + Pull request updated successfully + + + + + + + + + { + e.preventDefault(); + if (prUrl === "#") return; + void openLink(prUrl); + }} + style={{ + fontWeight: 600, + fontSize: "14px", + display: "block", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + color: "var(--fgColor-accent, var(--color-accent-fg))", + textDecoration: "none", + }} + > + {pr.title || submittedTitle} + {pr.number && ( + + #{pr.number} + + )} + + + {owner}/{repo} + + + + + ); +} + +function EditPRApp() { + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [prState, setPRState] = useState("open"); + const [isDraft, setIsDraft] = useState(false); + const [maintainerCanModify, setMaintainerCanModify] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [successPR, setSuccessPR] = useState(null); + const [initialValues, setInitialValues] = useState(null); + const [isLoadingPR, setIsLoadingPR] = useState(false); + const [submittedTitle, setSubmittedTitle] = useState(""); + + const [availableBranches, setAvailableBranches] = useState([]); + const [baseBranch, setBaseBranch] = useState(""); + const [branchesLoading, setBranchesLoading] = useState(false); + const [baseFilter, setBaseFilter] = useState(""); + + const [availableReviewers, setAvailableReviewers] = useState([]); + const [selectedReviewers, setSelectedReviewers] = useState([]); + const [reviewersLoading, setReviewersLoading] = useState(false); + const [reviewersFilter, setReviewersFilter] = useState(""); + + const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({ + appName: "github-mcp-server-edit-pull-request", + }); + + const owner = (toolInput?.owner as string) || ""; + const repo = (toolInput?.repo as string) || ""; + const pullNumber = asNumber(toolInput?.pullNumber); + + useEffect(() => { + setTitle(""); + setBody(""); + setPRState("open"); + setIsDraft(false); + setMaintainerCanModify(false); + setBaseBranch(""); + setSelectedReviewers([]); + setAvailableBranches([]); + setAvailableReviewers([]); + setBaseFilter(""); + setReviewersFilter(""); + setInitialValues(null); + setSuccessPR(null); + setError(null); + setSubmittedTitle(""); + }, [toolInput]); + + useEffect(() => { + if (!app || !owner || !repo || !pullNumber) return; + + let cancelled = false; + + const loadPullRequest = async () => { + setIsLoadingPR(true); + try { + const result = await callTool("pull_request_read", { method: "get", owner, repo, pullNumber }); + if (cancelled) return; + + if (result.isError) { + const textContent = result.content?.find((c) => c.type === "text"); + const errorMessage = textContent && textContent.type === "text" ? textContent.text : "Failed to load pull request"; + setError(errorMessage); + return; + } + + const textContent = result.content?.find((c) => c.type === "text"); + if (!textContent || textContent.type !== "text" || !textContent.text) { + setError("Pull request details were not returned"); + return; + } + + const prData = JSON.parse(textContent.text) as Record; + const initialState = buildInitialState(prData, owner); + const toolInputReviewers = reviewersFromValues(toolInput?.reviewers); + + setInitialValues(initialState); + setTitle(asString(toolInput?.title) ?? initialState.title); + setBody(asString(toolInput?.body) ?? initialState.body); + setPRState(parsePRState(asString(toolInput?.state) ?? initialState.state)); + setIsDraft(asBoolean(toolInput?.draft) ?? initialState.draft); + setBaseBranch(asString(toolInput?.base) ?? initialState.base); + setMaintainerCanModify(asBoolean(toolInput?.maintainer_can_modify) ?? initialState.maintainerCanModify); + setSelectedReviewers(toolInputReviewers ?? extractRequestedReviewers(prData, owner)); + } catch (e) { + if (!cancelled) { + setError(e instanceof Error ? e.message : "Failed to load pull request"); + } + } finally { + if (!cancelled) setIsLoadingPR(false); + } + }; + + loadPullRequest(); + return () => { + cancelled = true; + }; + }, [app, callTool, owner, repo, pullNumber, toolInput]); + + useEffect(() => { + if (!owner || !repo || !app) return; + + let cancelled = false; + + const loadBranches = async () => { + setBranchesLoading(true); + try { + const result = await callTool("ui_get", { method: "branches", owner, repo }); + if (cancelled) return; + if (result && !result.isError && result.content) { + const textContent = result.content.find((c: { type: string }) => c.type === "text"); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const branches = (data.branches || data || []).map( + (b: { name: string; protected?: boolean }) => ({ name: b.name, protected: b.protected || false }) + ); + setAvailableBranches(branches); + const defaultBranch = branches.find((b: BranchItem) => b.name === "main" || b.name === "master"); + if (defaultBranch) setBaseBranch((prev) => prev || defaultBranch.name); + } + } + } catch (e) { + console.error("Failed to load branches:", e); + } finally { + if (!cancelled) setBranchesLoading(false); + } + }; + + const loadReviewers = async () => { + setReviewersLoading(true); + try { + const result = await callTool("ui_get", { method: "reviewers", owner, repo }); + if (cancelled) return; + if (result && !result.isError && result.content) { + const textContent = result.content.find((c: { type: string }) => c.type === "text"); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const users = (data.users || []).map( + (u: { login: string; avatar_url?: string }) => ({ + kind: "user" as const, + id: u.login, + text: u.login, + avatar: u.avatar_url, + }) + ); + const teams = (data.teams || []).map( + (t: { slug: string; name?: string; org: string }) => ({ + kind: "team" as const, + id: `${t.org}/${t.slug}`, + text: `${t.org}/${t.slug}`, + org: t.org, + }) + ); + setAvailableReviewers([...users, ...teams]); + } + } + } catch (e) { + console.error("Failed to load reviewers:", e); + } finally { + if (!cancelled) setReviewersLoading(false); + } + }; + + loadBranches(); + loadReviewers(); + + return () => { + cancelled = true; + }; + }, [owner, repo, app, callTool]); + + useEffect(() => { + if (availableReviewers.length === 0) return; + setSelectedReviewers((prev) => + prev.map((reviewer) => + availableReviewers.find((available) => available.id === reviewer.id || available.text === reviewer.text) || reviewer + ) + ); + }, [availableReviewers]); + + const filteredBaseBranches = useMemo(() => { + if (!baseFilter.trim()) return availableBranches; + return availableBranches.filter((branch) => branch.name.toLowerCase().includes(baseFilter.toLowerCase())); + }, [availableBranches, baseFilter]); + + const filteredReviewers = useMemo(() => { + if (!reviewersFilter.trim()) return availableReviewers; + const lowerFilter = reviewersFilter.toLowerCase(); + return availableReviewers.filter((reviewer) => + reviewer.text.toLowerCase().includes(lowerFilter) || reviewer.id.toLowerCase().includes(lowerFilter) + ); + }, [availableReviewers, reviewersFilter]); + + const handleSubmit = useCallback(async () => { + if (!title.trim()) { setError("Title is required"); return; } + if (!owner || !repo || !pullNumber) { setError("Pull request information not available"); return; } + if (!baseBranch) { setError("Base branch is required"); return; } + if (!initialValues) { setError("Pull request details are still loading"); return; } + + const selectedReviewerValues = selectedReviewers.map(reviewerValue); + const params: Record = { owner, repo, pullNumber, _ui_submitted: true }; + + if (title.trim() !== initialValues.title) params.title = title.trim(); + if (body !== initialValues.body) params.body = body; + if (prState !== initialValues.state) params.state = prState; + if (baseBranch !== initialValues.base) params.base = baseBranch; + if (isDraft !== initialValues.draft) params.draft = isDraft; + if (maintainerCanModify !== initialValues.maintainerCanModify) params.maintainer_can_modify = maintainerCanModify; + if (!sameReviewerValues(selectedReviewerValues, initialValues.reviewers)) params.reviewers = selectedReviewerValues; + + const hasChanges = Object.keys(params).some((key) => !["owner", "repo", "pullNumber", "_ui_submitted"].includes(key)); + if (!hasChanges) { + setError("No changes to update"); + return; + } + + setIsSubmitting(true); + setError(null); + setSubmittedTitle(title); + + try { + const result = await callTool("update_pull_request", params); + + if (result.isError) { + const textContent = result.content?.find((c) => c.type === "text"); + const errorMessage = textContent && textContent.type === "text" ? textContent.text : "Failed to update pull request"; + setError(errorMessage); + } else { + const textContent = result.content?.find((c) => c.type === "text"); + if (textContent && textContent.type === "text" && textContent.text) { + const prData = JSON.parse(textContent.text); + setSuccessPR(prData); + void setModelContext({ + structuredContent: prData, + content: [ + { + type: "text", + text: `Pull request #${pullNumber} in ${owner}/${repo} was updated by the user via the edit-pull-request view.`, + }, + ], + }); + } + } + } catch (e) { + setError(e instanceof Error ? e.message : "An error occurred"); + } finally { + setIsSubmitting(false); + } + }, [title, body, owner, repo, pullNumber, baseBranch, initialValues, selectedReviewers, prState, isDraft, maintainerCanModify, callTool, setModelContext]); + + if (successPR) { + return ( + + + + ); + } + + if (!app && !appError) { + return ( + + + + + + ); + } + + if (appError) { + return ( + + {appError.message} + + ); + } + + if (toolInput === null) { + return ( + + + + + + ); + } + + if (!owner || !repo || !pullNumber) { + return ( + + Pull request owner, repo, and pullNumber are required. + + ); + } + + return ( + + + + + + + + #{pullNumber} · {owner}/{repo} + {title && ( + + {title} + + )} + + + + {error && {error}} + + {isLoadingPR && !initialValues ? ( + + + Loading pull request... + + ) : ( + <> + + Title + setTitle(e.target.value)} + placeholder="Title" + block + contrast + /> + + + + + Description + + + + + + base + + span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}> + {baseBranch || "Select base"} + + + + + setBaseFilter(e.target.value)} + size="small" + block + /> + + + {branchesLoading ? ( + Loading... + ) : filteredBaseBranches.length === 0 ? ( + No branches found + ) : ( + filteredBaseBranches.map((branch) => ( + { setBaseBranch(branch.name); setBaseFilter(""); }} + > + {branch.name} + {branch.protected && } + + )) + )} + + + + + + + + state + + + + + + + + setIsDraft(e.target.checked)} /> + Mark as draft + + + + + reviewers + + + {selectedReviewers.length === 0 ? ( + "No reviewers" + ) : ( + <> + Reviewers + {selectedReviewers.length} + + )} + + + + setReviewersFilter(e.target.value)} + size="small" + block + /> + + + {reviewersLoading ? ( + Loading... + ) : filteredReviewers.length === 0 ? ( + No reviewers available + ) : ( + filteredReviewers.map((reviewer) => ( + r.id === reviewer.id)} + onSelect={() => { + setSelectedReviewers((prev) => + prev.some((r) => r.id === reviewer.id) + ? prev.filter((r) => r.id !== reviewer.id) + : [...prev, reviewer] + ); + }} + > + + {reviewer.kind === "user" ? ( + reviewer.avatar ? ( + + ) : ( + + ) + ) : ( + + )} + + {reviewer.text} + + )) + )} + + + + {selectedReviewers.length > 0 && ( + + {selectedReviewers.map((reviewer) => ( + + ))} + + )} + + + + + setMaintainerCanModify(e.target.checked)} /> + Allow maintainer edits + + + + + + )} + + + ); +} + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/ui/src/apps/pr-edit/index.html b/ui/src/apps/pr-edit/index.html new file mode 100644 index 0000000000..9fa60aa992 --- /dev/null +++ b/ui/src/apps/pr-edit/index.html @@ -0,0 +1,12 @@ + + + + + + Edit pull request + + +
+ + + diff --git a/ui/src/apps/pr-write/App.tsx b/ui/src/apps/pr-write/App.tsx new file mode 100644 index 0000000000..769523d41b --- /dev/null +++ b/ui/src/apps/pr-write/App.tsx @@ -0,0 +1,812 @@ +import { StrictMode, useState, useCallback, useEffect, useMemo } from "react"; +import { createRoot } from "react-dom/client"; +import { + Box, + Text, + TextInput, + Button, + Flash, + Spinner, + FormControl, + ActionMenu, + ActionList, + Checkbox, + ButtonGroup, + CounterLabel, + Label, +} from "@primer/react"; +import { + GitPullRequestIcon, + CheckCircleIcon, + RepoIcon, + LockIcon, + GitBranchIcon, + TriangleDownIcon, + PersonIcon, + PeopleIcon, +} from "@primer/octicons-react"; +import { AppProvider } from "../../components/AppProvider"; +import { useMcpApp } from "../../hooks/useMcpApp"; +import { MarkdownEditor } from "../../components/MarkdownEditor"; + +interface PRResult { + ID?: string; + number?: number; + title?: string; + url?: string; + html_url?: string; + URL?: string; +} + +interface RepositoryItem { + id: string; + owner: string; + name: string; + fullName: string; + isPrivate: boolean; +} + +interface BranchItem { + name: string; + protected: boolean; +} + +type ReviewerItem = { kind: "user" | "team"; id: string; text: string; avatar?: string; org?: string }; + +function reviewerFromValue(value: string): ReviewerItem { + if (value.includes("/")) { + const [org, slug] = value.split("/", 2); + return { kind: "team", id: `${org}/${slug}`, text: `${org}/${slug}`, org }; + } + return { kind: "user", id: value, text: value }; +} + +function reviewerValue(reviewer: ReviewerItem): string { + return reviewer.kind === "team" ? reviewer.id : reviewer.text; +} + +function SuccessView({ + pr, + 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 || "#"; + + return ( + + + + + + + Pull request created successfully + + + + + + + + + { + // 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", + display: "block", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + color: "var(--fgColor-accent, var(--color-accent-fg))", + textDecoration: "none", + }} + > + {pr.title || submittedTitle} + {pr.number && ( + + #{pr.number} + + )} + + + {owner}/{repo} + + + + + ); +} + +function CreatePRApp() { + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [successPR, setSuccessPR] = useState(null); + + // Branch state + const [availableBranches, setAvailableBranches] = useState([]); + const [baseBranch, setBaseBranch] = useState(""); + const [headBranch, setHeadBranch] = useState(""); + const [branchesLoading, setBranchesLoading] = useState(false); + const [baseFilter, setBaseFilter] = useState(""); + const [headFilter, setHeadFilter] = useState(""); + + // Options + const [isDraft, setIsDraft] = useState(false); + const [maintainerCanModify, setMaintainerCanModify] = useState(true); + const [availableReviewers, setAvailableReviewers] = useState([]); + const [selectedReviewers, setSelectedReviewers] = useState([]); + const [reviewersLoading, setReviewersLoading] = useState(false); + const [reviewersFilter, setReviewersFilter] = useState(""); + + // Repository state + const [selectedRepo, setSelectedRepo] = useState(null); + const [repoSearchResults, setRepoSearchResults] = useState([]); + const [repoSearchLoading, setRepoSearchLoading] = useState(false); + const [repoFilter, setRepoFilter] = useState(""); + + const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({ + appName: "github-mcp-server-create-pull-request", + }); + + const owner = selectedRepo?.owner || (toolInput?.owner as string) || ""; + const repo = selectedRepo?.name || (toolInput?.repo as string) || ""; + const [submittedTitle, setSubmittedTitle] = useState(""); + + // Reset all transient form/result state when toolInput changes (new invocation). + // Without this, the SuccessView from a previous submit stays visible and stale + // form values bleed through because the prefill effect below only sets when + // toolInput has truthy values and never clears. The repo is re-initialized from + // the new invocation here (rather than in a separate effect) so it isn't wiped + // by this reset. + useEffect(() => { + setTitle(""); + setBody(""); + setHeadBranch(""); + setBaseBranch(""); + setIsDraft(false); + setMaintainerCanModify(true); + setSuccessPR(null); + setError(null); + setSubmittedTitle(""); + // Clear branch list and filters so a new invocation doesn't briefly show stale + // branches from the previous repo (or allow selecting invalid options) before the + // new repo's ui_get branches call resolves. + setAvailableBranches([]); + setBaseFilter(""); + setHeadFilter(""); + setAvailableReviewers([]); + setSelectedReviewers([]); + setReviewersFilter(""); + if (toolInput?.owner && toolInput?.repo) { + setSelectedRepo({ + id: `${toolInput.owner}/${toolInput.repo}`, + owner: toolInput.owner as string, + name: toolInput.repo as string, + fullName: `${toolInput.owner}/${toolInput.repo}`, + isPrivate: false, + }); + } else { + setSelectedRepo(null); + } + }, [toolInput]); + + // Pre-fill from toolInput + useEffect(() => { + if (toolInput?.title) setTitle(toolInput.title as string); + if (toolInput?.body) setBody(toolInput.body as string); + if (toolInput?.head) setHeadBranch(toolInput.head as string); + if (toolInput?.base) setBaseBranch(toolInput.base as string); + if (toolInput?.draft) setIsDraft(toolInput.draft as boolean); + if (toolInput?.maintainer_can_modify !== undefined) { + setMaintainerCanModify(toolInput.maintainer_can_modify as boolean); + } + if (Array.isArray(toolInput?.reviewers)) { + setSelectedReviewers((toolInput.reviewers as string[]).map(reviewerFromValue)); + } + }, [toolInput]); + + // Search repositories + useEffect(() => { + if (!app || !repoFilter.trim()) { + setRepoSearchResults([]); + return; + } + + const searchRepos = async () => { + setRepoSearchLoading(true); + try { + const result = await callTool("search_repositories", { query: repoFilter, perPage: 10 }); + if (result && !result.isError && result.content) { + const textContent = result.content.find((c) => c.type === "text"); + if (textContent && textContent.type === "text" && textContent.text) { + const data = JSON.parse(textContent.text); + const repos = (data.repositories || data.items || []).map( + (r: { id?: number; owner?: { login?: string } | string; name?: string; full_name?: string; private?: boolean }) => ({ + id: String(r.id || r.full_name), + owner: typeof r.owner === 'string' ? r.owner : r.owner?.login || r.full_name?.split('/')[0] || '', + name: r.name || '', + fullName: r.full_name || '', + isPrivate: r.private || false, + }) + ); + setRepoSearchResults(repos); + } + } + } catch (e) { + console.error("Failed to search repositories:", e); + } finally { + setRepoSearchLoading(false); + } + }; + + const debounce = setTimeout(searchRepos, 300); + return () => clearTimeout(debounce); + }, [app, callTool, repoFilter]); + + // Load branches and reviewers when repo is selected + useEffect(() => { + if (!owner || !repo || !app) return; + + const loadBranches = async () => { + setBranchesLoading(true); + try { + const result = await callTool("ui_get", { method: "branches", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find((c: { type: string }) => c.type === "text"); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const branches = (data.branches || data || []).map( + (b: { name: string; protected?: boolean }) => ({ name: b.name, protected: b.protected || false }) + ); + setAvailableBranches(branches); + if (branches.length > 0) { + const defaultBranch = branches.find((b: BranchItem) => b.name === 'main' || b.name === 'master'); + // Functional update so a base branch already prefilled from + // toolInput.base (or chosen by the user) isn't overwritten by a + // stale closure value captured before the request resolved. + if (defaultBranch) setBaseBranch((prev) => prev || defaultBranch.name); + } + } + } + } catch (e) { + console.error("Failed to load branches:", e); + } finally { + setBranchesLoading(false); + } + }; + + const loadReviewers = async () => { + setReviewersLoading(true); + try { + const result = await callTool("ui_get", { method: "reviewers", owner, repo }); + if (result && !result.isError && result.content) { + const textContent = result.content.find((c: { type: string }) => c.type === "text"); + if (textContent && "text" in textContent) { + const data = JSON.parse(textContent.text as string); + const users = (data.users || []).map( + (u: { login: string; avatar_url?: string }) => ({ + kind: "user" as const, + id: u.login, + text: u.login, + avatar: u.avatar_url, + }) + ); + const teams = (data.teams || []).map( + (t: { slug: string; name?: string; org: string }) => ({ + kind: "team" as const, + id: `${t.org}/${t.slug}`, + text: `${t.org}/${t.slug}`, + org: t.org, + }) + ); + setAvailableReviewers([...users, ...teams]); + } + } + } catch (e) { + console.error("Failed to load reviewers:", e); + } finally { + setReviewersLoading(false); + } + }; + + loadBranches(); + loadReviewers(); + }, [owner, repo, app, callTool]); + + useEffect(() => { + if (availableReviewers.length === 0) return; + setSelectedReviewers((prev) => + prev.map((reviewer) => + availableReviewers.find((available) => available.id === reviewer.id || available.text === reviewer.text) || reviewer + ) + ); + }, [availableReviewers]); + + // Filters + const filteredBaseBranches = useMemo(() => { + if (!baseFilter.trim()) return availableBranches; + return availableBranches.filter((b) => b.name.toLowerCase().includes(baseFilter.toLowerCase())); + }, [availableBranches, baseFilter]); + + const filteredHeadBranches = useMemo(() => { + if (!headFilter.trim()) return availableBranches; + return availableBranches.filter((b) => b.name.toLowerCase().includes(headFilter.toLowerCase())); + }, [availableBranches, headFilter]); + + const filteredReviewers = useMemo(() => { + if (!reviewersFilter.trim()) return availableReviewers; + const lowerFilter = reviewersFilter.toLowerCase(); + return availableReviewers.filter((reviewer) => + reviewer.text.toLowerCase().includes(lowerFilter) || reviewer.id.toLowerCase().includes(lowerFilter) + ); + }, [availableReviewers, reviewersFilter]); + + const handleSubmit = useCallback(async () => { + if (!title.trim()) { setError("Title is required"); return; } + if (!owner || !repo) { setError("Repository information not available"); return; } + if (!baseBranch) { setError("Base branch is required"); return; } + if (!headBranch) { setError("Head branch is required"); return; } + if (baseBranch === headBranch) { setError("Base and head branches cannot be the same"); return; } + + setIsSubmitting(true); + setError(null); + setSubmittedTitle(title); + + try { + const result = await callTool("create_pull_request", { + ...(toolInput as Record | undefined), + owner, repo, + title: title.trim(), + body: body.trim(), + head: headBranch, + base: baseBranch, + draft: isDraft, + maintainer_can_modify: maintainerCanModify, + reviewers: selectedReviewers.map(reviewerValue), + _ui_submitted: true + }); + + if (result.isError) { + const errorText = result.content?.find((c) => c.type === "text"); + const errorMessage = errorText && errorText.type === "text" ? errorText.text : "Failed to create pull request"; + setError(errorMessage); + } else { + const textContent = result.content?.find((c) => c.type === "text"); + 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) { + setError(e instanceof Error ? e.message : "An error occurred"); + } finally { + setIsSubmitting(false); + } + }, [title, body, owner, repo, baseBranch, headBranch, isDraft, maintainerCanModify, selectedReviewers, toolInput, callTool, setModelContext]); + + if (successPR) { + return ( + + + + ); + } + + if (!app && !appError) { + return ( + + + + + + ); + } + + if (appError) { + return ( + + {appError.message} + + ); + } + + return ( + + + {/* Repository picker */} + + + + span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }} + > + {selectedRepo ? selectedRepo.fullName : "Select repository"} + + + + + setRepoFilter(e.target.value)} + sx={{ width: "100%" }} + size="small" + autoFocus + /> + + + {repoSearchLoading ? ( + + + + ) : repoSearchResults.length > 0 ? ( + repoSearchResults.map((r) => ( + { + setSelectedRepo(r); + setRepoFilter(""); + setAvailableBranches([]); + setBaseBranch(""); + setHeadBranch(""); + setAvailableReviewers([]); + setSelectedReviewers([]); + setReviewersFilter(""); + }} + > + + {r.isPrivate ? : } + + {r.fullName} + + )) + ) : selectedRepo ? ( + setRepoFilter("")}> + + {selectedRepo.isPrivate ? : } + + {selectedRepo.fullName} + + ) : ( + + Type to search repositories... + + )} + + + + + + + {/* Branch selectors */} + + + base + + span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}> + {baseBranch || "Select base"} + + + + + setBaseFilter(e.target.value)} + size="small" + block + /> + + + {branchesLoading ? ( + Loading... + ) : filteredBaseBranches.length === 0 ? ( + No branches found + ) : ( + filteredBaseBranches.map((branch) => ( + { setBaseBranch(branch.name); setBaseFilter(""); }} + > + {branch.name} + {branch.protected && } + + )) + )} + + + + + + + + + compare + + span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}> + {headBranch || "Select head"} + + + + + setHeadFilter(e.target.value)} + size="small" + block + /> + + + {branchesLoading ? ( + Loading... + ) : filteredHeadBranches.length === 0 ? ( + No branches found + ) : ( + filteredHeadBranches.map((branch) => ( + { setHeadBranch(branch.name); setHeadFilter(""); }} + > + {branch.name} + + )) + )} + + + + + + + {/* Error banner */} + {error && {error}} + + {/* Title */} + + Title + setTitle(e.target.value)} + placeholder="Title" + block + contrast + /> + + + {/* Description */} + + + Description + + + + + {/* Reviewers */} + + reviewers + + + {selectedReviewers.length === 0 ? ( + "No reviewers" + ) : ( + <> + Reviewers + {selectedReviewers.length} + + )} + + + + setReviewersFilter(e.target.value)} + size="small" + block + /> + + + {reviewersLoading ? ( + Loading... + ) : filteredReviewers.length === 0 ? ( + No reviewers available + ) : ( + filteredReviewers.map((reviewer) => ( + r.id === reviewer.id)} + onSelect={() => { + setSelectedReviewers((prev) => + prev.some((r) => r.id === reviewer.id) + ? prev.filter((r) => r.id !== reviewer.id) + : [...prev, reviewer] + ); + }} + > + + {reviewer.kind === "user" ? ( + reviewer.avatar ? ( + + ) : ( + + ) + ) : ( + + )} + + {reviewer.text} + + )) + )} + + + + {selectedReviewers.length > 0 && ( + + {selectedReviewers.map((reviewer) => ( + + ))} + + )} + + + {/* Options and Submit */} + + + setMaintainerCanModify(e.target.checked)} /> + Allow maintainer edits + + + + + + + + + + + setIsDraft(false)}> + + + + Create pull request + + Open a pull request that is ready for review + + + setIsDraft(true)}> + + + + Create draft pull request + + Cannot be merged until marked ready for review + + + + + + + + + + ); +} + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/ui/src/apps/pr-write/index.html b/ui/src/apps/pr-write/index.html new file mode 100644 index 0000000000..e05c57ed50 --- /dev/null +++ b/ui/src/apps/pr-write/index.html @@ -0,0 +1,12 @@ + + + + + + Create Pull Request + + +
+ + + diff --git a/ui/src/components/AppProvider.tsx b/ui/src/components/AppProvider.tsx new file mode 100644 index 0000000000..e27bf96a02 --- /dev/null +++ b/ui/src/components/AppProvider.tsx @@ -0,0 +1,54 @@ +import { ThemeProvider, BaseStyles, Box } from "@primer/react"; +import type { ReactNode, CSSProperties } from "react"; +import { useEffect, useMemo } from "react"; +import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import { FeedbackFooter } from "./FeedbackFooter"; + +interface AppProviderProps { + children: ReactNode; + hostContext?: McpUiHostContext; +} + +export function AppProvider({ children, hostContext }: AppProviderProps) { + const hostTheme = hostContext?.theme; + const hostVariables = hostContext?.styles?.variables; + + useEffect(() => { + // 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} + + + + + ); +} 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/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx new file mode 100644 index 0000000000..5ba25932d0 --- /dev/null +++ b/ui/src/components/MarkdownEditor.tsx @@ -0,0 +1,447 @@ +/** + * MarkdownEditor component using GitHub's official @github/markdown-toolbar-element + * with Primer React styling. This provides the same markdown editing experience + * used on github.com. + * + * @see https://github.com/github/markdown-toolbar-element + */ +import { useId, useRef, useState, useEffect } from "react"; +import { Box, Text, Button, IconButton, useTheme } from "@primer/react"; +import { + BoldIcon, + ItalicIcon, + QuoteIcon, + CodeIcon, + LinkIcon, + ListUnorderedIcon, + ListOrderedIcon, + TasklistIcon, + MarkdownIcon, +} from "@primer/octicons-react"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +// Import and register the web component +import "@github/markdown-toolbar-element"; + +// Declare types for the web component elements +declare global { + namespace JSX { + interface IntrinsicElements { + "markdown-toolbar": React.DetailedHTMLProps< + React.HTMLAttributes & { for: string }, + HTMLElement + >; + "md-bold": React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLElement + >; + "md-italic": React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLElement + >; + "md-quote": React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLElement + >; + "md-code": React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLElement + >; + "md-link": React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLElement + >; + "md-unordered-list": React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLElement + >; + "md-ordered-list": React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLElement + >; + "md-task-list": React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLElement + >; + } + } +} + +interface MarkdownEditorProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + minHeight?: number; +} + +export function MarkdownEditor({ + value, + onChange, + placeholder = "Add a description...", + minHeight = 150, +}: MarkdownEditorProps) { + const textareaId = useId(); + const textareaRef = useRef(null); + const [viewMode, setViewMode] = useState<"write" | "preview">("write"); + const { colorScheme } = useTheme(); + const isDark = colorScheme === "dark" || colorScheme === "dark_dimmed"; + + // Sync external value changes to textarea + useEffect(() => { + if (textareaRef.current && textareaRef.current.value !== value) { + textareaRef.current.value = value; + } + }, [value]); + + // Handle Enter key for list continuation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== "Enter" || e.shiftKey) return; + + const textarea = textareaRef.current; + if (!textarea) return; + + const { selectionStart, value: currentValue } = textarea; + + // Get the current line + const beforeCursor = currentValue.substring(0, selectionStart); + const lastNewline = beforeCursor.lastIndexOf("\n"); + const currentLine = beforeCursor.substring(lastNewline + 1); + + // Match different list patterns + const unorderedMatch = currentLine.match(/^(\s*)([-*])\s/); + const orderedMatch = currentLine.match(/^(\s*)(\d+)\.\s/); + const taskMatch = currentLine.match(/^(\s*)([-*])\s\[[ x]\]\s/); + + let prefix = ""; + let isEmpty = false; + + if (taskMatch) { + const indent = taskMatch[1]; + const marker = taskMatch[2]; + // Check if the line only has the list marker with no content + isEmpty = currentLine.trim() === `${marker} [ ]` || currentLine.trim() === `${marker} [x]`; + prefix = `${indent}${marker} [ ] `; + } else if (orderedMatch) { + const indent = orderedMatch[1]; + const num = parseInt(orderedMatch[2], 10); + // Check if the line only has the list marker + isEmpty = currentLine.trim() === `${num}.`; + prefix = `${indent}${num + 1}. `; + } else if (unorderedMatch) { + const indent = unorderedMatch[1]; + const marker = unorderedMatch[2]; + // Check if the line only has the list marker + isEmpty = currentLine.trim() === marker; + prefix = `${indent}${marker} `; + } + + if (prefix) { + e.preventDefault(); + + if (isEmpty) { + // If just the list marker, remove it and exit list + const newValue = currentValue.substring(0, lastNewline + 1) + currentValue.substring(selectionStart); + onChange(newValue); + // Set cursor position after React updates + requestAnimationFrame(() => { + if (textarea) { + textarea.selectionStart = textarea.selectionEnd = lastNewline + 1; + textarea.focus(); + } + }); + } else { + // Continue the list on the next line + const afterCursor = currentValue.substring(selectionStart); + const newValue = beforeCursor + "\n" + prefix + afterCursor; + onChange(newValue); + // Set cursor position after the prefix + const newCursorPos = selectionStart + 1 + prefix.length; + requestAnimationFrame(() => { + if (textarea) { + textarea.selectionStart = textarea.selectionEnd = newCursorPos; + textarea.focus(); + } + }); + } + } + }; + + return ( + + {/* Header with tabs and toolbar */} + + {/* Write/Preview tabs */} + + + + + + {/* Toolbar - uses GitHub's official markdown-toolbar-element */} + {viewMode === "write" && ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + + {/* Content area */} + {viewMode === "write" ? ( +
Remote ServerLocal Server