diff --git a/.github/ISSUE_TEMPLATE/insiders-feedback.md b/.github/ISSUE_TEMPLATE/insiders-feedback.md new file mode 100644 index 0000000000..5b1f87f8ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/insiders-feedback.md @@ -0,0 +1,14 @@ +--- +name: Insiders Feedback +about: Give feedback related to a GitHub MCP Server Insiders feature +title: "Insiders Feedback: " +labels: '' +assignees: '' + +--- + +Version: Insiders + +Feature: + +Feedback: diff --git a/.github/actions/build-ui/action.yml b/.github/actions/build-ui/action.yml new file mode 100644 index 0000000000..229057d5cb --- /dev/null +++ b/.github/actions/build-ui/action.yml @@ -0,0 +1,38 @@ +name: Build UI +description: Restore cached UI HTML artifacts, or set up Node and run script/build-ui on cache miss. + +runs: + using: composite + steps: + - name: Cache UI artifacts + id: cache-ui + uses: actions/cache@v5 + with: + path: | + pkg/github/ui_dist/get-me.html + pkg/github/ui_dist/issue-write.html + pkg/github/ui_dist/pr-write.html + key: ui-dist-v1-${{ hashFiles('ui/package-lock.json', 'ui/package.json', 'ui/index.html', 'ui/tsconfig*.json', 'ui/vite.config.ts', 'ui/src/**', 'ui/scripts/**') }} + enableCrossOsArchive: true + + - name: Set up Node.js + if: steps.cache-ui.outputs.cache-hit != 'true' + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: npm + cache-dependency-path: ui/package-lock.json + + - name: Build UI + if: steps.cache-ui.outputs.cache-hit != 'true' + shell: bash + run: script/build-ui + + - name: Report UI cache status + shell: bash + run: | + if [ "${{ steps.cache-ui.outputs.cache-hit }}" = "true" ]; then + echo "UI artifacts restored from cache (skipped build)." + else + echo "UI artifacts rebuilt from source." + fi diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f1b4cf9cb6..975df2a633 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -94,7 +94,7 @@ go test ./pkg/github -run TestGetMe - **go.mod / go.sum:** Go module dependencies (Go 1.24.0+) - **.golangci.yml:** Linter configuration (v2 format, ~15 linters enabled) -- **Dockerfile:** Multi-stage build (golang:1.25.3-alpine → distroless) +- **Dockerfile:** Multi-stage build (golang:1.25.8-alpine → distroless) - **server.json:** MCP server metadata for registry - **.goreleaser.yaml:** Release automation config - **.gitignore:** Excludes bin/, dist/, vendor/, *.DS_Store, github-mcp-server binary @@ -243,7 +243,6 @@ All workflows run on push/PR unless noted. Located in `.github/workflows/`: - **GITHUB_HOST** - For GitHub Enterprise Server (prefix with `https://`) - **GITHUB_TOOLSETS** - Comma-separated toolset list (overrides --toolsets flag) - **GITHUB_READ_ONLY** - Set to "1" for read-only mode -- **GITHUB_DYNAMIC_TOOLSETS** - Set to "1" for dynamic toolset discovery - **UPDATE_TOOLSNAPS** - Set to "true" when running tests to update snapshots - **GITHUB_MCP_SERVER_E2E_TOKEN** - Token for e2e tests - **GITHUB_MCP_SERVER_E2E_DEBUG** - Set to "true" for in-process e2e debugging @@ -273,7 +272,7 @@ server.json - MCP server registry metadata `cmd/github-mcp-server/main.go` - Uses cobra for CLI, viper for config, supports: - `stdio` command (default) - MCP stdio transport - `generate-docs` command - Documentation generation -- Flags: --toolsets, --read-only, --dynamic-toolsets, --gh-host, --log-file +- Flags: --toolsets, --read-only, --gh-host, --log-file ## Important Reminders diff --git a/.github/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/ai-issue-assessment.yml b/.github/workflows/ai-issue-assessment.yml index 7481ce6db0..189ae959fa 100644 --- a/.github/workflows/ai-issue-assessment.yml +++ b/.github/workflows/ai-issue-assessment.yml @@ -6,9 +6,6 @@ on: jobs: ai-issue-assessment: - if: > - (github.event.action == 'opened' && github.event.issue.labels[0] == null) || - (github.event.action == 'labeled' && github.event.label.name == 'bug') runs-on: ubuntu-latest permissions: issues: write @@ -23,8 +20,8 @@ jobs: uses: github/ai-assessment-comment-labeler@e3bedc38cfffa9179fe4cee8f7ecc93bffb3fee7 # v1.0.1 with: token: ${{ secrets.GITHUB_TOKEN }} - ai_review_label: 'bug, enhancement' + ai_review_label: "request ai review" issue_number: ${{ github.event.issue.number }} issue_body: ${{ github.event.issue.body }} - prompts_directory: '.github/prompts' - labels_to_prompts_mapping: 'bug,bug-report-review.prompt.yml|default,default-issue-review.prompt.yml' + prompts_directory: ".github/prompts" + labels_to_prompts_mapping: "bug,bug-report-review.prompt.yml|default,default-issue-review.prompt.yml" diff --git a/.github/workflows/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/conformance.yml b/.github/workflows/conformance.yml deleted file mode 100644 index 92524ea171..0000000000 --- a/.github/workflows/conformance.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Conformance Test - -on: - pull_request: - -permissions: - contents: read - -jobs: - conformance: - runs-on: ubuntu-latest - - steps: - - name: Check out code - uses: actions/checkout@v6 - with: - # Fetch full history to access merge-base - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: "go.mod" - - - name: Download dependencies - run: go mod download - - - name: Run conformance test - id: conformance - run: | - # Run conformance test, capture stdout for summary - script/conformance-test > conformance-summary.txt 2>&1 || true - - # Output the summary - cat conformance-summary.txt - - # Check result - if grep -q "RESULT: ALL TESTS PASSED" conformance-summary.txt; then - echo "status=passed" >> $GITHUB_OUTPUT - else - echo "status=differences" >> $GITHUB_OUTPUT - fi - - - name: Generate Job Summary - run: | - # Add the full markdown report to the job summary - echo "# MCP Server Conformance Report" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Comparing PR branch against merge-base with \`origin/main\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # Extract and append the report content (skip the header since we added our own) - tail -n +5 conformance-report/CONFORMANCE_REPORT.md >> $GITHUB_STEP_SUMMARY - - echo "" >> $GITHUB_STEP_SUMMARY - echo "---" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # Add interpretation note - if [ "${{ steps.conformance.outputs.status }}" = "passed" ]; then - echo "✅ **All conformance tests passed** - No behavioral differences detected." >> $GITHUB_STEP_SUMMARY - else - echo "⚠️ **Differences detected** - Review the diffs above to ensure changes are intentional." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Common expected differences:" >> $GITHUB_STEP_SUMMARY - echo "- New tools/toolsets added" >> $GITHUB_STEP_SUMMARY - echo "- Tool descriptions updated" >> $GITHUB_STEP_SUMMARY - echo "- Capability changes (intentional improvements)" >> $GITHUB_STEP_SUMMARY - fi diff --git a/.github/workflows/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-ossf-slsa3-publish.yml b/.github/workflows/go-ossf-slsa3-publish.yml new file mode 100644 index 0000000000..8375acfc50 --- /dev/null +++ b/.github/workflows/go-ossf-slsa3-publish.yml @@ -0,0 +1,38 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# This workflow lets you compile your Go project using a SLSA3 compliant builder. +# This workflow will generate a so-called "provenance" file describing the steps +# that were performed to generate the final binary. +# The project is an initiative of the OpenSSF (openssf.org) and is developed at +# https://github.com/slsa-framework/slsa-github-generator. +# The provenance file can be verified using https://github.com/slsa-framework/slsa-verifier. +# For more information about SLSA and how it improves the supply-chain, visit slsa.dev. + +name: SLSA Go releaser +on: + workflow_dispatch: + release: + types: [created] + +permissions: readall + +jobs: + # ======================================================================================================================================== + # Prerequesite: Create a .slsa-goreleaser.yml in the root directory of your project. + # See format in https://github.com/slsa-framework/slsa-github-generator/blob/main/internal/builders/go/README.md#configuration-file + #========================================================================================================================================= + build: + permissions: + id-token: write # To sign. + contents: write # To upload release assets. + actions: read # To read workflow path. + uses: slsa-framework/slsa-github-generator/.github/workflows/builder_go_slsa3.yml@v1.4.0 + with: + go-version: 1.17 + # ============================================================================================================= + # Optional: For more options, see https://github.com/slsa-framework/slsa-github-generator#golang-projects + # ============================================================================================================= + diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 9fca372081..1fea50114a 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -14,18 +14,30 @@ jobs: runs-on: ${{ matrix.os }} steps: + - name: Force git to use LF + # This step is required on Windows to work around go mod tidy -diff issues caused by CRLF line endings. + # TODO: replace with a checkout option when https://github.com/actions/checkout/issues/226 is implemented + if: runner.os == 'Windows' + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - name: Check out code uses: actions/checkout@v6 + - name: Build UI + uses: ./.github/actions/build-ui + - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: "go.mod" - - name: Download dependencies - run: go mod download + - name: Tidy dependencies + run: go mod tidy -diff - name: Run unit tests + 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/issue-labeler.yml b/.github/workflows/issue-labeler.yml new file mode 100644 index 0000000000..278bb8705d --- /dev/null +++ b/.github/workflows/issue-labeler.yml @@ -0,0 +1,19 @@ +name: Label issues for AI review +on: + issues: + types: + - reopened + - opened +jobs: + label_issues: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Add AI review label to issue + run: gh issue edit "$NUMBER" --add-label "$LABELS" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + LABELS: "request ai review" diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index 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 new file mode 100644 index 0000000000..62f08bacb0 --- /dev/null +++ b/.github/workflows/mcp-diff.yml @@ -0,0 +1,134 @@ +name: MCP Server Diff + +on: + pull_request: + push: + branches: [main] + tags: ['v*'] + +permissions: + contents: read + +jobs: + mcp-diff: + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: 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: "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: ${{ 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:** Differences may be intentional improvements." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Common expected differences:" >> $GITHUB_STEP_SUMMARY + echo "- New tools/toolsets added" >> $GITHUB_STEP_SUMMARY + echo "- Tool descriptions updated" >> $GITHUB_STEP_SUMMARY + echo "- Capability changes (intentional improvements)" >> $GITHUB_STEP_SUMMARY + + 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 5684108b06..dc0a5f3a31 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,18 @@ bin/ .DS_Store # binary -github-mcp-server +/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 92ed525816..65d0f9e1ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,13 @@ -FROM golang:1.25.4-alpine AS build +FROM node:26-alpine@sha256:7c6af15abe4e3de859690e7db171d0d711bf37d27528eddfe625b2fe89e097f8 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.10-alpine@sha256:8d22e29d960bc50cd025d93d5b7c7d220b1ee9aa7a239b3c8f55a57e987e8d45 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/main.go + -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:58695f439f772a00009c8f6be4c183f824c1f556d74b313c30900f167e4772f8 # 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 d7e60e199b..139f980be2 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,60 @@ [![Go Report Card](https://goreportcard.com/badge/github.com/github/github-mcp-server)](https://goreportcard.com/report/github.com/github/github-mcp-server) +# GitHub MCP Server -# GitHub MCP Server +## What It Is -The GitHub MCP Server connects AI tools directly to GitHub's platform. This gives AI agents, assistants, and chatbots the ability to read repositories and code files, manage issues and PRs, analyze code, and automate workflows. All through natural language interactions. +The **GitHub MCP Server** is an official Model Context Protocol (MCP) server that acts as a bridge between AI tools (like Claude, Copilot) and GitHub's platform. It allows AI agents to understand and manage GitHub resources through natural language commands. -### Use Cases +**Think of it as:** An API gateway that translates AI requests into GitHub operations (managing repos, issues, PRs, workflows, discussions, etc.). -- Repository Management: Browse and query code, search files, analyze commits, and understand project structure across any repository you have access to. -- Issue & PR Automation: Create, update, and manage issues and pull requests. Let AI help triage bugs, review code changes, and maintain project boards. -- CI/CD & Workflow Intelligence: Monitor GitHub Actions workflow runs, analyze build failures, manage releases, and get insights into your development pipeline. -- Code Analysis: Examine security findings, review Dependabot alerts, understand code patterns, and get comprehensive insights into your codebase. -- Team Collaboration: Access discussions, manage notifications, analyze team activity, and streamline processes for your team. +## Core Technology -Built for developers who want to connect their AI tools to GitHub context and capabilities, from simple natural language queries to complex multi-step agent workflows. +- **Language:** Go 1.24+ (96.7% of codebase) +- **Protocol:** Model Context Protocol (MCP) for AI-to-GitHub communication +- **GitHub Integration:** Uses google/go-github SDK + GraphQL API +- **Size:** ~38k lines across 70 Go files +- **Deployment:** CLI server with stdio interface; containerized via Docker +## Main Components + +| Component | Purpose | Status | +|-----------|---------|--------| +| **github-mcp-server** | Primary MCP server - handles all AI↔GitHub communication | Core focus | +| **mcpcurl** | Testing/debugging utility for MCP interactions | Secondary (maintain, don't break) | +| **pkg/github** | MCP tools implementation (70+ GitHub operations) | Main logic | +| **pkg/toolsets** | Tool configuration and management | Infrastructure | +| **internal/ghmcp** | Server core logic and request handling | Infrastructure | + +## Key Capabilities + +The server enables AI to: +- ✅ Search & browse repositories, issues, discussions, PRs +- ✅ Create/update issues and manage workflows +- ✅ Analyze code, commits, and repository metadata +- ✅ Handle authentication and GitHub API operations +- ✅ Support multi-language documentation via i18n + +## Code Quality Standards + +Since this is a **popular open-source project**, it maintains high standards: +- Clean, atomic commits with clear messages +- Self-documenting code (code over comments) +- Comprehensive test coverage with `-race` flag +- Automated linting and formatting +- Tool schema snapshots ensure intentional API changes + +## Critical Workflow + +Before committing changes: +1. **`script/lint`** - Format & lint code (~1s) +2. **`script/test`** - Run tests with race detection (~1s) +3. **`script/generate-docs`** - Update README if tools changed (~1s) + +These are **fast, must-run** validation steps. + +--- + +**Bottom Line:** This is a well-structured, production-grade Go service that powers AI-GitHub integration, with strong emphasis on code clarity, testing, and maintainability. --- ## Remote GitHub MCP Server @@ -83,9 +124,11 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block - **[GitHub Copilot in other IDEs](/docs/installation-guides/install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot - **[Claude Applications](/docs/installation-guides/install-claude.md)** - Installation guide for Claude Desktop and Claude Code CLI -- **[Codex](/docs/installation-guides/install-codex.md)** - Installation guide for Open AI Codex +- **[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. @@ -98,6 +141,49 @@ See [Remote Server Documentation](docs/remote-server.md) for full details on rem When no toolsets are specified, [default toolsets](#default-toolset) are used. +#### Insiders Mode + +> **Try new features early!** The remote server offers an insiders version with early access to new features and experimental tools. + + + + + + + +
Using URL PathUsing Header
+ +```json +{ + "servers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/insiders" + } + } +} +``` + + + +```json +{ + "servers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "X-MCP-Insiders": "true" + } + } + } +} +``` + +
+ +See [Remote Server Documentation](docs/remote-server.md#insiders-mode) for more details and examples, and [Insiders Features](docs/insiders-features.md) for a full list of what's available. + #### GitHub Enterprise ##### GitHub Enterprise Cloud with data residency (ghe.com) @@ -109,7 +195,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": { @@ -168,7 +254,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": { @@ -323,11 +409,14 @@ Optionally, you can add a similar example (i.e. without the mcp key) to a file c For other MCP host applications, please refer to our installation guides: +- **[Copilot CLI](docs/installation-guides/install-copilot-cli.md)** - Installation guide for GitHub Copilot CLI - **[GitHub Copilot in other IDEs](/docs/installation-guides/install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot - **[Claude Code & Claude Desktop](docs/installation-guides/install-claude.md)** - Installation guide for Claude Code and Claude Desktop - **[Cursor](docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE - **[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)**. @@ -354,6 +443,17 @@ If you don't have Docker, you can use `go build` to build the binary in the } ``` +### CLI utilities + +The `github-mcp-server` binary includes a few CLI subcommands that are helpful for debugging and exploring the server. + +- `github-mcp-server tool-search ""` searches tools by name, description, and input parameter names. Use `--max-results` to return more matches. +Example (color output requires a TTY; use `docker run -t` (or `-it`) when running in Docker): +```bash +docker run -it --rm ghcr.io/github/github-mcp-server tool-search "issue" --max-results 5 +github-mcp-server tool-search "issue" --max-results 5 +``` + ## Tool Configuration The GitHub MCP Server supports enabling or disabling specific groups of functionalities via the `--toolsets` flag. This allows you to control which GitHub API capabilities are available to your AI tools. Enabling only the toolsets that you need can help the LLM with tool choice and reduce the context size. @@ -384,7 +484,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**: @@ -419,7 +519,7 @@ You can also configure specific tools using the `--tools` flag. Tools can be use - Tools, toolsets, and dynamic toolsets can all be used together - Read-only mode takes priority: write tools are skipped if `--read-only` is set, even if explicitly requested via `--tools` - Tool names must match exactly (e.g., `get_file_contents`, not `getFileContents`). Invalid tool names will cause the server to fail at startup with an error message -- 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 @@ -485,6 +585,31 @@ To keep the default configuration and add additional toolsets: GITHUB_TOOLSETS="default,stargazers" ./github-mcp-server ``` +### Insiders Mode + +The local GitHub MCP Server offers an insiders version with early access to new features and experimental tools. + +1. **Using Command Line Argument**: + + ```bash + ./github-mcp-server --insiders + ``` + +2. **Using Environment Variable**: + + ```bash + GITHUB_INSIDERS=true ./github-mcp-server + ``` + +When using Docker: + +```bash +docker run -i --rm \ + -e GITHUB_PERSONAL_ACCESS_TOKEN= \ + -e GITHUB_INSIDERS=true \ + ghcr.io/github/github-mcp-server +``` + ### Available Toolsets The following sets of tools are available: @@ -495,6 +620,7 @@ 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 | | 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 | @@ -527,108 +653,53 @@ The following sets of tools are available: workflow Actions -- **cancel_workflow_run** - Cancel workflow run +- **actions_get** - Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts) - **Required OAuth Scopes**: `repo` + - `method`: The method to execute (string, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) + - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: + - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method. + - Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods. + - Provide an artifact ID for 'download_workflow_run_artifact' method. + - Provide a job ID for 'get_workflow_job' method. + (string, required) -- **delete_workflow_run_logs** - Delete workflow logs +- **actions_list** - List GitHub Actions workflows in a repository - **Required OAuth Scopes**: `repo` + - `method`: The action to perform (string, required) - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (default: 1) (number, optional) + - `per_page`: Results per page for pagination (default: 30, max: 100) (number, optional) - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **download_workflow_run_artifact** - Download workflow artifact + - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: + - Do not provide any resource ID for 'list_workflows' method. + - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method, or omit to list all workflow runs in the repository. + - Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods. + (string, optional) + - `workflow_jobs_filter`: Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs' (object, optional) + - `workflow_runs_filter`: Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs' (object, optional) + +- **actions_run_trigger** - Trigger GitHub Actions workflow actions - **Required OAuth Scopes**: `repo` - - `artifact_id`: The unique identifier of the artifact (number, required) + - `inputs`: Inputs the workflow accepts. Only used for 'run_workflow' method. (object, optional) + - `method`: The method to execute (string, required) - `owner`: Repository owner (string, required) + - `ref`: The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method. (string, optional) - `repo`: Repository name (string, required) + - `run_id`: The ID of the workflow run. Required for all methods except 'run_workflow'. (number, optional) + - `workflow_id`: The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method. (string, optional) -- **get_job_logs** - Get job logs +- **get_job_logs** - Get GitHub Actions workflow job logs - **Required OAuth Scopes**: `repo` - - `failed_only`: When true, gets logs for all failed jobs in run_id (boolean, optional) - - `job_id`: The unique identifier of the workflow job (required for single job logs) (number, optional) + - `failed_only`: When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided. (boolean, optional) + - `job_id`: The unique identifier of the workflow job. Required when getting logs for a single job. (number, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `return_content`: Returns actual log content instead of URLs (boolean, optional) - - `run_id`: Workflow run ID (required when using failed_only) (number, optional) + - `run_id`: The unique identifier of the workflow run. Required when failed_only is true to get logs for all failed jobs in the run. (number, optional) - `tail_lines`: Number of lines to return from the end of the log (number, optional) -- **get_workflow_run** - Get workflow run - - **Required OAuth Scopes**: `repo` - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **get_workflow_run_logs** - Get workflow run logs - - **Required OAuth Scopes**: `repo` - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **get_workflow_run_usage** - Get workflow usage - - **Required OAuth Scopes**: `repo` - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **list_workflow_jobs** - List workflow jobs - - **Required OAuth Scopes**: `repo` - - `filter`: Filters jobs by their completed_at timestamp (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) - - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **list_workflow_run_artifacts** - List workflow artifacts - - **Required OAuth Scopes**: `repo` - - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **list_workflow_runs** - List workflow runs - - **Required OAuth Scopes**: `repo` - - `actor`: Returns someone's workflow runs. Use the login for the user who created the workflow run. (string, optional) - - `branch`: Returns workflow runs associated with a branch. Use the name of the branch. (string, optional) - - `event`: Returns workflow runs for a specific event type (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) - - `repo`: Repository name (string, required) - - `status`: Returns workflow runs with the check run status (string, optional) - - `workflow_id`: The workflow ID or workflow file name (string, required) - -- **list_workflows** - List workflows - - **Required OAuth Scopes**: `repo` - - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `repo`: Repository name (string, required) - -- **rerun_failed_jobs** - Rerun failed jobs - - **Required OAuth Scopes**: `repo` - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **rerun_workflow_run** - Rerun workflow run - - **Required OAuth Scopes**: `repo` - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: The unique identifier of the workflow run (number, required) - -- **run_workflow** - Run workflow - - **Required OAuth Scopes**: `repo` - - `inputs`: Inputs the workflow accepts (object, optional) - - `owner`: Repository owner (string, required) - - `ref`: The git reference for the workflow. The reference can be a branch or tag name. (string, required) - - `repo`: Repository name (string, required) - - `workflow_id`: The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml) (string, required) -
@@ -646,6 +717,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) @@ -676,6 +749,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 @@ -689,6 +782,8 @@ The following sets of tools are available: - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` - `owner`: The owner of the repository. (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: The name of the repository. (string, required) - `severity`: Filter dependabot alerts by severity (string, optional) - `state`: Filter dependabot alerts by state. Defaults to open (string, optional) @@ -699,6 +794,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) @@ -709,6 +821,7 @@ The following sets of tools are available: - **Required OAuth Scopes**: `repo` - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `discussionNumber`: Discussion Number (number, required) + - `includeReplies`: When true, each top-level comment will include its replies nested within it (up to 100 replies per comment, which is the GitHub API maximum). Defaults to false. (boolean, optional) - `owner`: Repository owner (string, required) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) @@ -784,13 +897,7 @@ The following sets of tools are available: - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) -- **assign_copilot_to_issue** - Assign Copilot to issue - - **Required OAuth Scopes**: `repo` - - `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) @@ -811,7 +918,7 @@ The following sets of tools are available: - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: The name of the repository (string, required) -- **issue_write** - Create or update issue. +- **issue_write** - Create or update issue - **Required OAuth Scopes**: `repo` - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) @@ -880,13 +987,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) @@ -964,84 +1071,52 @@ The following sets of tools are available: project Projects -- **add_project_item** - Add project item - - **Required OAuth Scopes**: `project` - - `item_id`: The numeric ID of the issue or pull request to add to the project. (number, required) - - `item_type`: The item's type, either issue or pull_request. (string, required) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `project_number`: The project's number. (number, required) - -- **delete_project_item** - Delete project item - - **Required OAuth Scopes**: `project` - - `item_id`: The internal project item ID to delete from the project (not the issue or pull request ID). (number, required) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `project_number`: The project's number. (number, required) - -- **get_project** - Get project +- **projects_get** - Get details of GitHub Projects resources - **Required OAuth Scopes**: `read:project` - **Accepted OAuth Scopes**: `project`, `read:project` - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `project_number`: The project's number (number, required) - -- **get_project_field** - Get project field - - **Required OAuth Scopes**: `read:project` - - **Accepted OAuth Scopes**: `project`, `read:project` - - `field_id`: The field's id. (number, required) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `project_number`: The project's number. (number, required) - -- **get_project_item** - Get project item - - **Required OAuth Scopes**: `read:project` - - **Accepted OAuth Scopes**: `project`, `read:project` - - `fields`: Specific list of field IDs to include in the response (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. (string[], optional) - - `item_id`: The item's ID. (number, required) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `project_number`: The project's number. (number, required) - -- **list_project_fields** - List project fields - - **Required OAuth Scopes**: `read:project` - - **Accepted OAuth Scopes**: `project`, `read:project` - - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) - - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `per_page`: Results per page (max 50) (number, optional) - - `project_number`: The project's number. (number, required) - -- **list_project_items** - List project items + - `field_id`: The field's ID. Required for 'get_project_field' method. (number, optional) + - `fields`: Specific list of field IDs to include in the response when getting a project item (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. Only used for 'get_project_item' method. (string[], optional) + - `item_id`: The item's ID. Required for 'get_project_item' method. (number, optional) + - `method`: The method to execute (string, required) + - `owner`: The owner (user or organization login). The name is not case sensitive. (string, optional) + - `owner_type`: Owner type (user or org). If not provided, will be automatically detected. (string, optional) + - `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` - **Accepted OAuth Scopes**: `project`, `read:project` - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - - `fields`: Field IDs to include (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. (string[], optional) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) + - `fields`: Field IDs to include when listing project items (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method. (string[], optional) + - `method`: The action to perform (string, required) + - `owner`: The owner (user or organization login). The name is not case sensitive. (string, required) + - `owner_type`: Owner type (user or org). If not provided, will automatically try both. (string, optional) - `per_page`: Results per page (max 50) (number, optional) - - `project_number`: The project's number. (number, required) - - `query`: Query string for advanced filtering of project items using GitHub's project filtering syntax. (string, optional) + - `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) -- **list_projects** - List projects - - **Required OAuth Scopes**: `read:project` - - **Accepted OAuth Scopes**: `project`, `read:project` - - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) - - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `per_page`: Results per page (max 50) (number, optional) - - `query`: Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: "roadmap is:open", "is:open feature planning". (string, optional) - -- **update_project_item** - Update project item +- **projects_write** - Manage GitHub Projects - **Required OAuth Scopes**: `project` - - `item_id`: The unique identifier of the project item. This is not the issue or pull request ID. (number, required) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `project_number`: The project's number. (number, required) - - `updated_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {"id": 123456, "value": "New Value"} (object, required) + - `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). 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)
@@ -1062,6 +1137,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) @@ -1096,15 +1179,17 @@ 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. + 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned. 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. + 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. (string, required) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) @@ -1112,7 +1197,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) @@ -1121,12 +1206,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` @@ -1179,7 +1259,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` @@ -1249,9 +1329,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` @@ -1260,6 +1343,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) @@ -1280,9 +1371,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) @@ -1309,6 +1408,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) @@ -1417,45 +1518,34 @@ 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) - **list_copilot_spaces** - List Copilot Spaces -
-
- GitHub Support Docs Search - **github_support_docs_search** - Retrieve documentation relevant to answer GitHub product and support questions. Support topics include: GitHub Actions Workflows, Authentication, GitHub Support Inquiries, Pull Request Practices, Repository Maintenance, GitHub Pages, GitHub Packages, GitHub Discussions, Copilot Spaces - `query`: Input from the user about the question they need answered. This is the latest raw unedited user message. You should ALWAYS leave the user message as it is, you should never modify it. (string, required)
+ -## 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 -``` +GitHub Support Docs Search -When using Docker, you can pass the toolsets as environment variables: +- **github_support_docs_search** - Retrieve documentation relevant to answer GitHub product and support questions. Support topics include: GitHub Actions Workflows, Authentication, GitHub Support Inquiries, Pull Request Practices, Repository Maintenance, GitHub Pages, GitHub Packages, GitHub Discussions, Copilot Spaces + - `query`: Input from the user about the question they need answered. This is the latest raw unedited user message. You should ALWAYS leave the user message as it is, you should never modify it. (string, required) -```bash -docker run -i --rm \ - -e GITHUB_PERSONAL_ACCESS_TOKEN= \ - -e GITHUB_DYNAMIC_TOOLSETS=1 \ - ghcr.io/github/github-mcp-server -``` +
## Read-Only Mode @@ -1541,7 +1631,35 @@ For example, to override the `TOOL_ADD_ISSUE_COMMENT_DESCRIPTION` tool, you can set the following environment variable: ```sh -export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description" + 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 +GITHUB_MCP_SERVER_NAME="ghes-mcp-server" +GITHUB_MCP_SERVER_TITLE="GHES MCP Server" ``` ## Library Usage 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 14d771330f..78ed8361a8 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,8 +59,16 @@ func generateReadmeDocs(readmePath string) error { // Create translation helper t, _ := translations.TranslationHelper() - // (not available to regular users) while including tools with FeatureFlagDisable. - r := github.NewInventory(t).WithToolsets([]string{"all"}).Build() + // 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"}). + WithFeatureChecker(noFeatureFlagsChecker). + Build() // Generate toolsets documentation toolsetsDoc := generateToolsetsDoc(r) @@ -143,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) } @@ -153,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 "" } @@ -212,6 +229,15 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { } } + // MCP App UI metadata (only rendered when the remote_mcp_ui_apps flag + // applied to the inventory; for the no-flags README this section is + // stripped by inventory.ToolsForRegistration before rendering). + if ui, ok := tool.Tool.Meta["ui"].(map[string]any); ok { + if uri, ok := ui["resourceUri"].(string); ok && uri != "" { + fmt.Fprintf(buf, " - **MCP App UI**: `%s`\n", uri) + } + } + // Parameters if tool.Tool.InputSchema == nil { buf.WriteString(" - No parameters required") @@ -233,7 +259,7 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { 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" @@ -288,15 +314,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 { @@ -318,14 +335,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) @@ -341,22 +358,24 @@ func generateRemoteToolsetsDoc() string { t, _ := translations.TranslationHelper() // Build inventory - stateless - r := github.NewInventory(t).Build() + // Build() can only fail if WithTools specifies invalid tools - not used here + r, _ := github.NewInventory(t).Build() // Generate table header (icon is combined with Name column) buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n") 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) - formattedName := formatToolsetName(idStr) apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr) readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr) @@ -372,9 +391,9 @@ func generateRemoteToolsetsDoc() string { readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig) icon := octiconImg(ts.Icon, "../") - fmt.Fprintf(&buf, "| %s
%s | %s | %s | %s | [read-only](%s) | %s |\n", + fmt.Fprintf(&buf, "| %s
`%s` | %s | %s | %s | [read-only](%s) | %s |\n", icon, - formattedName, + idStr, ts.Description, apiURL, installLink, @@ -397,7 +416,6 @@ func generateRemoteOnlyToolsetsDoc() string { for _, ts := range github.RemoteOnlyToolsets() { idStr := string(ts.ID) - formattedName := formatToolsetName(idStr) apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr) readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr) @@ -413,9 +431,9 @@ func generateRemoteOnlyToolsetsDoc() string { readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig) icon := octiconImg(ts.Icon, "../") - fmt.Fprintf(&buf, "| %s
%s | %s | %s | %s | [read-only](%s) | %s |\n", + fmt.Fprintf(&buf, "| %s
`%s` | %s | %s | %s | [read-only](%s) | %s |\n", icon, - formattedName, + idStr, ts.Description, apiURL, installLink, @@ -426,6 +444,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/list_scopes.go b/cmd/github-mcp-server/list_scopes.go index 2d1817500b..d8b8bf3922 100644 --- a/cmd/github-mcp-server/list_scopes.go +++ b/cmd/github-mcp-server/list_scopes.go @@ -121,7 +121,10 @@ func runListScopes() error { inventoryBuilder = inventoryBuilder.WithTools(enabledTools) } - inv := inventoryBuilder.Build() + inv, err := inventoryBuilder.Build() + if err != nil { + return fmt.Errorf("failed to build inventory: %w", err) + } // Collect all tools and their scopes output := collectToolScopes(inv, readOnly) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index cfb68be4eb..558fdb9980 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,18 +85,80 @@ 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"), LogFilePath: viper.GetString("log-file"), 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"), + 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() { @@ -99,8 +170,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") @@ -108,13 +179,21 @@ func init() { rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)") rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size") rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode") + rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") + // HTTP-specific flags + httpCmd.Flags().Int("port", 8082, "HTTP server port") + 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")) @@ -122,10 +201,16 @@ func init() { _ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host")) _ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size")) _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) + _ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) - + _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) + _ = 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() { @@ -133,7 +218,6 @@ func initConfig() { viper.SetEnvPrefix("github") viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) viper.AutomaticEnv() - } func main() { diff --git a/cmd/mcpcurl/README.md b/cmd/mcpcurl/README.md index 717ea207fd..e1227d585d 100644 --- a/cmd/mcpcurl/README.md +++ b/cmd/mcpcurl/README.md @@ -16,7 +16,7 @@ be executed against the configured MCP server. ## Installation ### Prerequisites -- Go 1.21 or later +- Go 1.24 or later - Access to the GitHub MCP Server from either Docker or local build ### Build from Source diff --git a/cmd/mcpcurl/main.go b/cmd/mcpcurl/main.go index 17b4bc77c4..f35e6926c3 100644 --- a/cmd/mcpcurl/main.go +++ b/cmd/mcpcurl/main.go @@ -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) @@ -432,7 +432,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 +445,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/mcpcurl b/cmd/mcpcurl/mcpcurl deleted file mode 100755 index 6ea4eeda62..0000000000 Binary files a/cmd/mcpcurl/mcpcurl and /dev/null differ diff --git a/docs/feature-flags.md b/docs/feature-flags.md new file mode 100644 index 0000000000..0b75a61bac --- /dev/null +++ b/docs/feature-flags.md @@ -0,0 +1,289 @@ +# Feature Flags + +Feature flags let you opt into experimental tool behavior on top of the default +GitHub MCP Server surface. Insiders Mode turns on a curated subset of these +flags automatically — see [Insiders Features](./insiders-features.md) for that +specific set. + +For background on how flags resolve at request time, see the [resolution +section in the Insiders docs](./insiders-features.md#how-feature-flags-are-resolved). + +## Enabling a flag + +| Method | Remote Server | Local Server | +|--------|---------------|--------------| +| Header | `X-MCP-Features: ,` | N/A | +| CLI flag | N/A | `--features=,` | +| Environment variable | N/A | `GITHUB_FEATURES=,` | + +Only flags listed in +[`AllowedFeatureFlags`](../pkg/github/feature_flags.go) can be enabled by +end users. Insiders-only flags are not user-toggleable. + +--- + +## Tools affected by each flag + +The list below is regenerated from the Go source. For each user-controllable +feature flag, it lists every tool whose **inventory or input schema** differs +from the default — either because the flag introduces a new tool, or because +it selects a flag-aware variant of an existing tool. Flags that only affect +runtime behavior (such as output formatting) won't appear here. + + + +### `remote_mcp_ui_apps` + +- **create_pull_request** - Open new pull request + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/pr-write` + - `base`: Branch to merge into (string, required) + - `body`: PR description (string, optional) + - `draft`: Create as draft PR (boolean, optional) + - `head`: Branch containing changes (string, required) + - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `title`: PR title (string, required) + +- **get_me** - Get my user profile + - **MCP App UI**: `ui://github-mcp-server/get-me` + - No parameters required + +- **issue_write** - Create or update issue + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/issue-write` + - `assignees`: Usernames to assign to this issue (string[], optional) + - `body`: Issue body content (string, optional) + - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) + - `issue_number`: Issue number to update (number, optional) + - `labels`: Labels to apply to this issue (string[], optional) + - `method`: Write operation to perform on a single issue. + Options are: + - 'create' - creates a new issue. + - 'update' - updates an existing issue. + (string, required) + - `milestone`: Milestone number (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `state`: New state (string, optional) + - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) + - `title`: Issue title (string, optional) + - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + +### `remote_mcp_issue_fields` + +- **issue_write** - Create or update issue + - **Required OAuth Scopes**: `repo` + - `assignees`: Usernames to assign to this issue (string[], optional) + - `body`: Issue body content (string, optional) + - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) + - `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'. (object[], optional) + - `issue_number`: Issue number to update (number, optional) + - `labels`: Labels to apply to this issue (string[], optional) + - `method`: Write operation to perform on a single issue. + Options are: + - 'create' - creates a new issue. + - 'update' - updates an existing issue. + (string, required) + - `milestone`: Milestone number (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `state`: New state (string, optional) + - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) + - `title`: Issue title (string, optional) + - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + +- **list_issue_fields** - List issue fields + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required) + - `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional) + +- **list_issues** - List issues + - **Required OAuth Scopes**: `repo` + - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) + - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) + - `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional) + - `labels`: Filter by labels (string[], optional) + - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional) + - `owner`: Repository owner (string, required) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - `since`: Filter by date (ISO 8601 timestamp) (string, optional) + - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) + +### `issues_granular` + +- **add_sub_issue** - Add Sub-Issue + - **Required OAuth Scopes**: `repo` + - `issue_number`: The parent issue number (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `replace_parent`: If true, reparent the sub-issue if it already has a parent (boolean, optional) + - `repo`: Repository name (string, required) + - `sub_issue_id`: The ID of the sub-issue to add. ID is not the same as issue number (number, required) + +- **create_issue** - Create Issue + - **Required OAuth Scopes**: `repo` + - `body`: Issue body content (optional) (string, optional) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `title`: Issue title (string, required) + +- **remove_sub_issue** - Remove Sub-Issue + - **Required OAuth Scopes**: `repo` + - `issue_number`: The parent issue number (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `sub_issue_id`: The ID of the sub-issue to remove. ID is not the same as issue number (number, required) + +- **reprioritize_sub_issue** - Reprioritize Sub-Issue + - **Required OAuth Scopes**: `repo` + - `after_id`: The ID of the sub-issue to place this after (either after_id OR before_id should be specified) (number, optional) + - `before_id`: The ID of the sub-issue to place this before (either after_id OR before_id should be specified) (number, optional) + - `issue_number`: The parent issue number (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `sub_issue_id`: The ID of the sub-issue to reorder. ID is not the same as issue number (number, required) + +- **set_issue_fields** - Set Issue Fields + - **Required OAuth Scopes**: `repo` + - `fields`: Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, or 'single_select_option_id' (the GraphQL node ID of the option) for single select fields. Set 'delete' to true to remove a field value. (object[], required) + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_assignees** - Update Issue Assignees + - **Required OAuth Scopes**: `repo` + - `assignees`: GitHub usernames to assign to this issue (string[], required) + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_body** - Update Issue Body + - **Required OAuth Scopes**: `repo` + - `body`: The new body content for the issue (string, required) + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_labels** - Update Issue Labels + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `labels`: Labels to apply to this issue. ([], required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_milestone** - Update Issue Milestone + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `milestone`: The milestone number to set on the issue (integer, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_state** - Update Issue State + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `state`: The new state for the issue (string, required) + - `state_reason`: The reason for the state change (only for closed state) (string, optional) + +- **update_issue_title** - Update Issue Title + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `title`: The new title for the issue (string, required) + +- **update_issue_type** - Update Issue Type + - **Required OAuth Scopes**: `repo` + - `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 to request reviews from (string[], required) + +- **resolve_review_thread** - Resolve Review Thread + - **Required OAuth Scopes**: `repo` + - `threadID`: The node ID of the review thread to resolve (e.g., PRRT_kwDOxxx) (string, required) + +- **submit_pending_pull_request_review** - Submit Pending Pull Request Review + - **Required OAuth Scopes**: `repo` + - `body`: The review body text (optional) (string, optional) + - `event`: The review action to perform (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + +- **unresolve_review_thread** - Unresolve Review Thread + - **Required OAuth Scopes**: `repo` + - `threadID`: The node ID of the review thread to unresolve (e.g., PRRT_kwDOxxx) (string, required) + +- **update_pull_request_body** - Update Pull Request Body + - **Required OAuth Scopes**: `repo` + - `body`: The new body content for the pull request (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + +- **update_pull_request_draft_state** - Update Pull Request Draft State + - **Required OAuth Scopes**: `repo` + - `draft`: Set to true to convert to draft, false to mark as ready for review (boolean, required) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + +- **update_pull_request_state** - Update Pull Request State + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `state`: The new state for the pull request (string, required) + +- **update_pull_request_title** - Update Pull Request Title + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `title`: The new title for the pull request (string, required) + + diff --git a/docs/insiders-features.md b/docs/insiders-features.md new file mode 100644 index 0000000000..881030f020 --- /dev/null +++ b/docs/insiders-features.md @@ -0,0 +1,192 @@ +# 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) + - `title`: PR title (string, required) + +- **get_me** - Get my user profile + - **MCP App UI**: `ui://github-mcp-server/get-me` + - No parameters required + +- **issue_write** - Create or update issue + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/issue-write` + - `assignees`: Usernames to assign to this issue (string[], optional) + - `body`: Issue body content (string, optional) + - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) + - `issue_number`: Issue number to update (number, optional) + - `labels`: Labels to apply to this issue (string[], optional) + - `method`: Write operation to perform on a single issue. + Options are: + - 'create' - creates a new issue. + - 'update' - updates an existing issue. + (string, required) + - `milestone`: Milestone number (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `state`: New state (string, optional) + - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) + - `title`: Issue title (string, optional) + - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + +### `remote_mcp_issue_fields` + +- **issue_write** - Create or update issue + - **Required OAuth Scopes**: `repo` + - `assignees`: Usernames to assign to this issue (string[], optional) + - `body`: Issue body content (string, optional) + - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) + - `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'. (object[], optional) + - `issue_number`: Issue number to update (number, optional) + - `labels`: Labels to apply to this issue (string[], optional) + - `method`: Write operation to perform on a single issue. + Options are: + - 'create' - creates a new issue. + - 'update' - updates an existing issue. + (string, required) + - `milestone`: Milestone number (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `state`: New state (string, optional) + - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) + - `title`: Issue title (string, optional) + - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + +- **list_issue_fields** - List issue fields + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required) + - `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional) + +- **list_issues** - List issues + - **Required OAuth Scopes**: `repo` + - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) + - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) + - `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional) + - `labels`: Filter by labels (string[], optional) + - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional) + - `owner`: Repository owner (string, required) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - `since`: Filter by date (ISO 8601 timestamp) (string, optional) + - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) + + + +--- + +## MCP Apps + +[MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) is an extension to the Model Context Protocol that enables servers to deliver interactive user interfaces to end users. Instead of returning plain text that the LLM must interpret and relay, tools can render forms, profiles, and dashboards right in the chat using MCP Apps. + +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 be967f81de..46581aa77e 100644 --- a/docs/installation-guides/README.md +++ b/docs/installation-guides/README.md @@ -3,29 +3,42 @@ This directory contains detailed installation instructions for the GitHub MCP Server across different host applications and IDEs. Choose the guide that matches your development environment. ## Installation Guides by Host Application +- **[Copilot CLI](install-copilot-cli.md)** - Installation guide for GitHub Copilot CLI - **[GitHub Copilot in other IDEs](install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot - **[Antigravity](install-antigravity.md)** - Installation for Google Antigravity IDE -- **[Claude Applications](install-claude.md)** - Installation guide for Claude Web, Claude Desktop and Claude Code CLI +- **[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 | Host Application | Local GitHub MCP Support | Remote GitHub MCP Support | Prerequisites | Difficulty | |-----------------|---------------|----------------|---------------|------------| +| Copilot CLI | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Copilot in VS Code | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: VS Code 1.101+ | Easy | | Copilot Coding Agent | ✅ | ✅ Full (on by default; no auth needed) | Any _paid_ copilot license | Default on | | Copilot in Visual Studio | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: Visual Studio 17.14+ | Easy | | 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 @@ -95,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 ca8491afff..d66b34776b 100644 --- a/docs/installation-guides/install-claude.md +++ b/docs/installation-guides/install-claude.md @@ -28,15 +28,35 @@ echo -e ".env\n.mcp.json" >> .gitignore ### Remote Server Setup (Streamable HTTP) +> **Note**: For Claude Code versions **2.1.1 and newer**, use the `add-json` command format below. For older versions, see the [legacy command format](#for-older-versions-of-claude-code). +> +> **Windows / CLI note**: `claude mcp add-json` may return `Invalid input` when adding an HTTP server. If that happens, use the legacy `claude mcp add --transport http ...` command format below. + 1. Run the following command in the terminal (not in Claude Code CLI): ```bash -claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer YOUR_GITHUB_PAT" +claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer YOUR_GITHUB_PAT"}}' ``` -With an environment variable: +With an environment variable (Linux/macOS): ```bash -claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "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: +> - `local` (default): Available only to you in the current project (was called `project` in older versions) +> - `project`: Shared with everyone in the project via `.mcp.json` file +> - `user`: Available to you across all projects (was called `global` in older versions) +> +> Example: Add `--scope user` to the end of the command to make it available across all projects. + 2. Restart Claude Code 3. Run `claude mcp list` to see if the GitHub server is configured @@ -72,6 +92,28 @@ claude mcp list claude mcp get github ``` +### For Older Versions of Claude Code + +If you're using Claude Code version **2.1.0 or earlier**, use this legacy command format: + +```bash +claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer YOUR_GITHUB_PAT" +``` + +With an environment variable: +```bash +claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer $(grep GITHUB_PAT .env | cut -d '=' -f2)" +``` + +#### Windows (PowerShell) + +If you see `missing required argument 'name'`, put the server name immediately after `claude mcp add`: + +```powershell +$pat = "YOUR_GITHUB_PAT" +claude mcp add github --transport http https://api.githubcopilot.com/mcp/ -H "Authorization: Bearer $pat" +``` + --- ## Claude Desktop @@ -130,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 @@ -161,7 +271,4 @@ Add this codeblock to your `claude_desktop_config.json`: - The npm package `@modelcontextprotocol/server-github` is deprecated as of April 2025 - Remote server requires Streamable HTTP support (check your Claude version) -- Configuration scopes for Claude Code: - - `-s user`: Available across all projects - - `-s project`: Shared via `.mcp.json` file - - Default: `local` (current project only) +- For Claude Code configuration scopes, see the `--scope` flag documentation in the [Remote Server Setup](#remote-server-setup-streamable-http) section diff --git a/docs/installation-guides/install-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 new file mode 100644 index 0000000000..4ac5b3712c --- /dev/null +++ b/docs/installation-guides/install-copilot-cli.md @@ -0,0 +1,172 @@ +# Install GitHub MCP Server in Copilot CLI + +The GitHub MCP server comes pre-installed in Copilot CLI, with read-only tools enabled by default. + +## 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 +
+ +To set your PAT as an environment variable: + +```bash +# Add to your shell profile (~/.bashrc, ~/.zshrc, etc.) +export GITHUB_PERSONAL_ACCESS_TOKEN=your_token_here +``` + +
+ +### Method 1: Interactive Setup (Recommended) + +From an active Copilot CLI session, run the interactive command: + +```bash +/mcp add +``` + +Follow the prompts to configure the server. + +### Method 2: Manual Setup + +Create or edit the configuration file `~/.copilot/mcp-config.json` and add one of the following configurations: + +#### Remote Server + +Connect to the hosted MCP server: + +```json +{ + "mcpServers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer ${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} +``` + +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: + +```json +{ + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} +``` + +#### Binary + +You can download the latest binary release from the [GitHub releases page](https://github.com/github/github-mcp-server/releases) or build it from source by running: + +```bash +go build -o github-mcp-server ./cmd/github-mcp-server +``` + +Then configure (replace `/path/to/binary` with the actual path): + +```json +{ + "mcpServers": { + "github": { + "command": "/path/to/binary", + "args": ["stdio"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} +``` + +## Verification + +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 +- **Image pull failures**: Try `docker logout ghcr.io` then retry + +### Authentication Issues + +- **Invalid PAT**: Verify your GitHub PAT has correct scopes: + - `repo` - Repository operations + - `read:packages` - Docker image access (if using Docker) +- **Token expired**: Generate a new GitHub PAT + +### Configuration Issues + +- **Invalid JSON**: Validate your configuration: + ```bash + cat ~/.copilot/mcp-config.json | jq . + ``` + +## References + +- [Copilot CLI Documentation](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) diff --git a/docs/installation-guides/install-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 d7d0f72b12..0729aaee75 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,6 +19,26 @@ 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
`default` | Default toolset | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| apps
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/x/all | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/all/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%2Freadonly%22%7D) | +| workflow
`actions` | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | +| codescan
`code_security` | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | +| copilot
`copilot` | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | +| dependabot
`dependabot` | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | +| comment-discussion
`discussions` | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | +| logo-gist
`gists` | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | +| git-branch
`git` | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) | +| issue-opened
`issues` | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | +| tag
`labels` | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) | +| bell
`notifications` | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | +| organization
`orgs` | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) | +| project
`projects` | GitHub Projects related tools | https://api.githubcopilot.com/mcp/x/projects | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/projects/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%2Freadonly%22%7D) | +| git-pull-request
`pull_requests` | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) | +| repo
`repos` | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | +| shield-lock
`secret_protection` | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) | +| shield
`security_advisories` | Security advisories related tools | https://api.githubcopilot.com/mcp/x/security_advisories | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/security_advisories/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%2Freadonly%22%7D) | +| star
`stargazers` | GitHub Stargazers related tools | https://api.githubcopilot.com/mcp/x/stargazers | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/stargazers/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%2Freadonly%22%7D) | +| people
`users` | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) | | apps
all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | workflow
Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | codescan
Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | @@ -46,6 +66,8 @@ 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_spaces` | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | +| book
`github_support_docs_search` | Retrieve documentation to answer GitHub product and support questions. Topics include: GitHub Actions Workflows, Authentication, ... | https://api.githubcopilot.com/mcp/x/github_support_docs_search | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/github_support_docs_search/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%2Freadonly%22%7D) | | copilot
Copilot | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | | copilot
Copilot Spaces | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | | book
Github Support Docs Search | Retrieve documentation to answer GitHub product and support questions. Topics include: GitHub Actions Workflows, Authentication, ... | https://api.githubcopilot.com/mcp/x/github_support_docs_search | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/github_support_docs_search/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%2Freadonly%22%7D) | @@ -67,6 +89,9 @@ The Remote GitHub MCP server has optional headers equivalent to the Local server - `X-MCP-Lockdown`: Enables lockdown mode, hiding public issue details created by users without push access. - Equivalent to `GITHUB_LOCKDOWN_MODE` env var for Local server. - If this header is empty, "false", "f", "no", "n", "0", or "off" (ignoring whitespace and case), it will be interpreted as false. All other values are interpreted as true. +- `X-MCP-Insiders`: Enables insiders mode for early access to new features. + - Equivalent to `GITHUB_INSIDERS` env var or `--insiders` flag for Local server. + - If this header is empty, "false", "f", "no", "n", "0", or "off" (ignoring whitespace and case), it will be interpreted as false. All other values are interpreted as true. > **Looking for examples?** See the [Server Configuration Guide](./server-configuration.md) for common recipes like minimal setups, read-only mode, and combining tools with toolsets. @@ -84,18 +109,51 @@ Example: } ``` +### Insiders Mode + +The remote GitHub MCP Server offers an insiders version with early access to new features and experimental tools. You can enable insiders mode in two ways: + +1. **Via URL path** - Append `/insiders` to the URL: + + ```json + { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/insiders" + } + ``` + +2. **Via header** - Set the `X-MCP-Insiders` header to `true`: + + ```json + { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "X-MCP-Insiders": "true" + } + } + ``` + +Both methods can be combined with other path modifiers (like `/readonly`) and headers. + ### URL Path Parameters The Remote GitHub MCP server supports the following URL path patterns: - `/` - Default toolset (see ["default" toolset](../README.md#default-toolset)) - `/readonly` - Default toolset in read-only mode +- `/insiders` - Default toolset with insiders mode enabled +- `/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. +Note: `{toolset}` can only be a single toolset, not a comma-separated list. To combine multiple toolsets, use the `X-MCP-Toolsets` header instead. Path modifiers like `/readonly` and `/insiders` can be combined with the `X-MCP-Insiders` or `X-MCP-Readonly` headers. Example: diff --git a/docs/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..9228248ecb 100644 --- a/docs/toolsets-and-icons.md +++ b/docs/toolsets-and-icons.md @@ -161,7 +161,6 @@ icons := octicons.Icons("repo") | Labels | `tag` | | Stargazers | `star` | | Notifications | `bell` | -| Dynamic | `tools` | | Copilot | `copilot` | | Support Search | `book` | diff --git a/e2e.test b/e2e.test deleted file mode 100755 index 58505b3a24..0000000000 Binary files a/e2e.test and /dev/null differ 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 5322b47ec3..080cdcfd8e 100644 --- a/go.mod +++ b/go.mod @@ -1,54 +1,47 @@ 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/go-openapi/jsonpointer v0.19.5 // indirect - github.com/go-openapi/swag v0.21.1 // indirect - github.com/gorilla/css v1.0.1 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect - github.com/stretchr/objx v0.5.2 // indirect - github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/net v0.38.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect -) - -require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 - github.com/google/go-querystring v1.1.0 // indirect + github.com/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/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 - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.31.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.28.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 25cbf7fa95..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-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.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/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v5 v5.3.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= @@ -44,28 +35,27 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/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/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= +github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= 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= @@ -83,43 +73,59 @@ 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/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/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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.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= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-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 165886606a..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,95 +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 - - // 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: http.DefaultTransport, - token: cfg.Token, + Transport: &transport.BearerAuthTransport{ + Transport: &transport.GraphQLFeaturesTransport{ + Transport: http.DefaultTransport, + }, + 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 @@ -114,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) } @@ -164,117 +127,55 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { return nil, fmt.Errorf("failed to create GitHub clients: %w", err) } - enabledToolsets := resolveEnabledToolsets(cfg) - - // For instruction generation, we need actual toolset names (not nil). - // nil means "use defaults" in inventory, so expand it for instructions. - instructionToolsets := enabledToolsets - if instructionToolsets == nil { - instructionToolsets = github.GetDefaultToolsetIDs() - } - - // Create the MCP server - serverOpts := &mcp.ServerOptions{ - Instructions: github.GenerateInstructions(instructionToolsets), - 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{}, - } - } - - ghServer := github.NewServer(cfg.Version, serverOpts) - - // Add middlewares - ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext) - ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.rest, clients.gqlHTTP)) + // Create feature checker — resolves explicit features + insiders expansion + featureChecker := createFeatureChecker(cfg.EnabledFeatures, cfg.InsidersMode) // Create dependencies for tool handlers + obs, err := observability.NewExporters(cfg.Logger, metrics.NewNoopMetrics()) + if err != nil { + return nil, fmt.Errorf("failed to create observability exporters: %w", err) + } deps := github.NewBaseDeps( clients.rest, clients.gql, clients.raw, clients.repoAccess, cfg.Translator, - github.FeatureFlags{LockdownMode: cfg.LockdownMode}, + github.FeatureFlags{ + LockdownMode: cfg.LockdownMode, + }, cfg.ContentWindowSize, + featureChecker, + obs, ) - - // 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) - } - }) - // Build and register the tool/resource/prompt inventory inventoryBuilder := github.NewInventory(cfg.Translator). WithDeprecatedAliases(github.DeprecatedToolAliases). WithReadOnly(cfg.ReadOnly). - WithToolsets(enabledToolsets). + WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)). WithTools(github.CleanTools(cfg.EnabledTools)). - WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)) + WithExcludeTools(cfg.ExcludeTools). + WithServerInstructions(). + WithFeatureChecker(featureChecker) // Apply token scope filtering if scopes are known (for PAT filtering) if cfg.TokenScopes != nil { inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes)) } - inventory := inventoryBuilder.Build() - - if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 { - fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", ")) + inventory, err := inventoryBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build inventory: %w", err) } - // 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) + ghServer, err := github.NewMCPServer(ctx, &cfg, deps, inventory) + if err != nil { + return nil, fmt.Errorf("failed to create GitHub MCP server: %w", err) } - return ghServer, nil -} + ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.restUATransp, clients.gqlHTTP)) -// 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) - } -} - -// 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 - } + return ghServer, nil } type StdioServerConfig struct { @@ -299,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 @@ -322,6 +219,14 @@ type StdioServerConfig struct { // LockdownMode indicates if we should enable lockdown mode LockdownMode bool + // 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 } @@ -348,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. @@ -366,18 +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, @@ -427,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" { @@ -653,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) @@ -669,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 04c0989d42..6f0e3ac3f3 100644 --- a/internal/ghmcp/server_test.go +++ b/internal/ghmcp/server_test.go @@ -1,112 +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, - } - - // 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.go b/internal/toolsnaps/toolsnaps.go index 89d02e1ee5..4b25904ed3 100644 --- a/internal/toolsnaps/toolsnaps.go +++ b/internal/toolsnaps/toolsnaps.go @@ -67,15 +67,35 @@ func Test(toolName string, tool any) error { } func writeSnap(snapPath string, contents []byte) error { + // Sort the JSON keys recursively to ensure consistent output. + // We do this by unmarshaling and remarshaling, which ensures Go's JSON encoder + // sorts all map keys alphabetically at every level. + sortedJSON, err := sortJSONKeys(contents) + if err != nil { + return fmt.Errorf("failed to sort JSON keys: %w", err) + } + // Ensure the directory exists if err := os.MkdirAll(filepath.Dir(snapPath), 0700); err != nil { return fmt.Errorf("failed to create snapshot directory: %w", err) } // Write the snapshot file - if err := os.WriteFile(snapPath, contents, 0600); err != nil { + if err := os.WriteFile(snapPath, sortedJSON, 0600); err != nil { return fmt.Errorf("failed to write snapshot file: %w", err) } return nil } + +// sortJSONKeys recursively sorts all object keys in a JSON byte array by +// unmarshaling to map[string]any and remarshaling. Go's JSON encoder +// automatically sorts map keys alphabetically. +func sortJSONKeys(jsonData []byte) ([]byte, error) { + var data any + if err := json.Unmarshal(jsonData, &data); err != nil { + return nil, err + } + + return json.MarshalIndent(data, "", " ") +} diff --git a/internal/toolsnaps/toolsnaps_test.go b/internal/toolsnaps/toolsnaps_test.go index be9cadf7f1..b1138df866 100644 --- a/internal/toolsnaps/toolsnaps_test.go +++ b/internal/toolsnaps/toolsnaps_test.go @@ -131,3 +131,184 @@ func TestMalformedSnapshotJSON(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "failed to parse snapshot JSON for dummy", "expected error about malformed snapshot JSON") } + +func TestSortJSONKeys(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple object", + input: `{"z": 1, "a": 2, "m": 3}`, + expected: "{\n \"a\": 2,\n \"m\": 3,\n \"z\": 1\n}", + }, + { + name: "nested object", + input: `{"z": {"y": 1, "x": 2}, "a": 3}`, + expected: "{\n \"a\": 3,\n \"z\": {\n \"x\": 2,\n \"y\": 1\n }\n}", + }, + { + name: "array with objects", + input: `{"items": [{"z": 1, "a": 2}, {"y": 3, "b": 4}]}`, + expected: "{\n \"items\": [\n {\n \"a\": 2,\n \"z\": 1\n },\n {\n \"b\": 4,\n \"y\": 3\n }\n ]\n}", + }, + { + name: "deeply nested", + input: `{"z": {"y": {"x": 1, "a": 2}, "b": 3}, "m": 4}`, + expected: "{\n \"m\": 4,\n \"z\": {\n \"b\": 3,\n \"y\": {\n \"a\": 2,\n \"x\": 1\n }\n }\n}", + }, + { + name: "properties field like in toolsnaps", + input: `{"name": "test", "properties": {"repo": {"type": "string"}, "owner": {"type": "string"}, "page": {"type": "number"}}}`, + expected: "{\n \"name\": \"test\",\n \"properties\": {\n \"owner\": {\n \"type\": \"string\"\n },\n \"page\": {\n \"type\": \"number\"\n },\n \"repo\": {\n \"type\": \"string\"\n }\n }\n}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := sortJSONKeys([]byte(tt.input)) + require.NoError(t, err) + assert.Equal(t, tt.expected, string(result)) + }) + } +} + +func TestSortJSONKeysIdempotent(t *testing.T) { + // Given a JSON string that's already sorted + input := `{"a": 1, "b": {"x": 2, "y": 3}, "c": [{"m": 4, "n": 5}]}` + + // When we sort it once + sorted1, err := sortJSONKeys([]byte(input)) + require.NoError(t, err) + + // And sort it again + sorted2, err := sortJSONKeys(sorted1) + require.NoError(t, err) + + // Then the results should be identical + assert.Equal(t, string(sorted1), string(sorted2)) +} + +func TestToolSnapKeysSorted(t *testing.T) { + withIsolatedWorkingDir(t) + + // Given a tool with fields that could be in any order + type complexTool struct { + Name string `json:"name"` + Description string `json:"description"` + Properties map[string]any `json:"properties"` + Annotations map[string]any `json:"annotations"` + } + + tool := complexTool{ + Name: "test_tool", + Description: "A test tool", + Properties: map[string]any{ + "zzz": "last", + "aaa": "first", + "mmm": "middle", + "owner": map[string]any{"type": "string", "description": "Owner"}, + "repo": map[string]any{"type": "string", "description": "Repo"}, + }, + Annotations: map[string]any{ + "readOnly": true, + "title": "Test", + }, + } + + // When we write the snapshot + t.Setenv("UPDATE_TOOLSNAPS", "true") + err := Test("complex", tool) + require.NoError(t, err) + + // Then the snapshot file should have sorted keys + snapJSON, err := os.ReadFile("__toolsnaps__/complex.snap") + require.NoError(t, err) + + // Verify that the JSON is properly sorted by checking key order + var parsed map[string]any + err = json.Unmarshal(snapJSON, &parsed) + require.NoError(t, err) + + // Check that properties are sorted + propsJSON, _ := json.MarshalIndent(parsed["properties"], "", " ") + propsStr := string(propsJSON) + // The properties should have "aaa" before "mmm" before "zzz" + aaaIndex := -1 + mmmIndex := -1 + zzzIndex := -1 + for i, line := range propsStr { + if line == 'a' && i+2 < len(propsStr) && propsStr[i:i+3] == "aaa" { + aaaIndex = i + } + if line == 'm' && i+2 < len(propsStr) && propsStr[i:i+3] == "mmm" { + mmmIndex = i + } + if line == 'z' && i+2 < len(propsStr) && propsStr[i:i+3] == "zzz" { + zzzIndex = i + } + } + assert.Greater(t, mmmIndex, aaaIndex, "mmm should come after aaa") + assert.Greater(t, zzzIndex, mmmIndex, "zzz should come after mmm") +} + +func TestStructFieldOrderingSortedAlphabetically(t *testing.T) { + withIsolatedWorkingDir(t) + + // Given a struct with fields defined in non-alphabetical order + // This test ensures that struct field order doesn't affect the JSON output + type toolWithNonAlphabeticalFields struct { + ZField string `json:"zField"` // Should appear last in JSON + AField string `json:"aField"` // Should appear first in JSON + MField string `json:"mField"` // Should appear in the middle + } + + tool := toolWithNonAlphabeticalFields{ + ZField: "z value", + AField: "a value", + MField: "m value", + } + + // When we write the snapshot + t.Setenv("UPDATE_TOOLSNAPS", "true") + err := Test("struct_field_order", tool) + require.NoError(t, err) + + // Then the snapshot file should have alphabetically sorted keys despite struct field order + snapJSON, err := os.ReadFile("__toolsnaps__/struct_field_order.snap") + require.NoError(t, err) + + snapStr := string(snapJSON) + + // Find the positions of each field in the JSON string + aFieldIndex := -1 + mFieldIndex := -1 + zFieldIndex := -1 + for i := range len(snapStr) - 7 { + switch snapStr[i : i+6] { + case "aField": + aFieldIndex = i + case "mField": + mFieldIndex = i + case "zField": + zFieldIndex = i + } + } + + // Verify alphabetical ordering in the JSON output + require.NotEqual(t, -1, aFieldIndex, "aField should be present") + require.NotEqual(t, -1, mFieldIndex, "mField should be present") + require.NotEqual(t, -1, zFieldIndex, "zField should be present") + assert.Less(t, aFieldIndex, mFieldIndex, "aField should appear before mField") + assert.Less(t, mFieldIndex, zFieldIndex, "mField should appear before zField") + + // Also verify idempotency - running the test again should produce identical output + err = Test("struct_field_order", tool) + require.NoError(t, err) + + snapJSON2, err := os.ReadFile("__toolsnaps__/struct_field_order.snap") + require.NoError(t, err) + + assert.Equal(t, string(snapJSON), string(snapJSON2), "Multiple runs should produce identical output") +} diff --git a/pkg/buffer/buffer.go b/pkg/buffer/buffer.go index 14bcf95821..23cc818e1f 100644 --- a/pkg/buffer/buffer.go +++ b/pkg/buffer/buffer.go @@ -1,12 +1,17 @@ package buffer import ( - "bufio" + "bytes" "fmt" + "io" "net/http" "strings" ) +// maxLineSize is the maximum size for a single log line (10MB). +// GitHub Actions logs can contain extremely long lines (base64 content, minified JS, etc.) +const maxLineSize = 10 * 1024 * 1024 + // ProcessResponseAsRingBufferToEnd reads the body of an HTTP response line by line, // storing only the last maxJobLogLines lines using a ring buffer (sliding window). // This efficiently retains the most recent lines, overwriting older ones as needed. @@ -25,7 +30,11 @@ import ( // // The function uses a ring buffer to efficiently store only the last maxJobLogLines lines. // If the response contains more lines than maxJobLogLines, only the most recent lines are kept. +// Lines exceeding maxLineSize are truncated with a marker. func ProcessResponseAsRingBufferToEnd(httpResp *http.Response, maxJobLogLines int) (string, int, *http.Response, error) { + if maxJobLogLines <= 0 { + maxJobLogLines = 500 + } if maxJobLogLines > 100000 { maxJobLogLines = 100000 } @@ -35,34 +44,85 @@ func ProcessResponseAsRingBufferToEnd(httpResp *http.Response, maxJobLogLines in totalLines := 0 writeIndex := 0 - scanner := bufio.NewScanner(httpResp.Body) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + const readBufferSize = 64 * 1024 // 64KB read buffer + const maxDisplayLength = 1000 // Keep first 1000 chars of truncated lines - for scanner.Scan() { - line := scanner.Text() - totalLines++ + readBuf := make([]byte, readBufferSize) + var currentLine strings.Builder + lineTruncated := false + // storeLine saves the current line to the ring buffer and resets state + storeLine := func() { + line := currentLine.String() + if lineTruncated && len(line) > maxDisplayLength { + line = line[:maxDisplayLength] + } + if lineTruncated { + line += "... [TRUNCATED]" + } lines[writeIndex] = line validLines[writeIndex] = true + totalLines++ writeIndex = (writeIndex + 1) % maxJobLogLines + currentLine.Reset() + lineTruncated = false } - if err := scanner.Err(); err != nil { - return "", 0, httpResp, fmt.Errorf("failed to read log content: %w", err) + // accumulate adds bytes to currentLine up to maxLineSize, sets lineTruncated if exceeded + accumulate := func(data []byte) { + if lineTruncated { + return + } + remaining := maxLineSize - currentLine.Len() + if remaining <= 0 { + lineTruncated = true + return + } + if remaining > len(data) { + remaining = len(data) + } + currentLine.Write(data[:remaining]) + if currentLine.Len() >= maxLineSize { + lineTruncated = true + } } - var result []string - linesInBuffer := totalLines - if linesInBuffer > maxJobLogLines { - linesInBuffer = maxJobLogLines + for { + n, err := httpResp.Body.Read(readBuf) + if n > 0 { + chunk := readBuf[:n] + for len(chunk) > 0 { + newlineIdx := bytes.IndexByte(chunk, '\n') + if newlineIdx < 0 { + accumulate(chunk) + break + } + accumulate(chunk[:newlineIdx]) + storeLine() + chunk = chunk[newlineIdx+1:] + } + } + + if err == io.EOF { + if currentLine.Len() > 0 { + storeLine() + } + break + } + if err != nil { + return "", 0, httpResp, fmt.Errorf("failed to read log content: %w", err) + } } + var result []string + 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/buffer/buffer_test.go b/pkg/buffer/buffer_test.go new file mode 100644 index 0000000000..86308ec5e3 --- /dev/null +++ b/pkg/buffer/buffer_test.go @@ -0,0 +1,176 @@ +package buffer + +import ( + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProcessResponseAsRingBufferToEnd(t *testing.T) { + t.Run("normal lines", func(t *testing.T) { + body := "line1\nline2\nline3\n" + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(body)), + } + + result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 10) + if respOut != nil && respOut.Body != nil { + defer respOut.Body.Close() + } + require.NoError(t, err) + assert.Equal(t, 3, totalLines) + assert.Equal(t, "line1\nline2\nline3", result) + }) + + t.Run("ring buffer keeps last N lines", func(t *testing.T) { + body := "line1\nline2\nline3\nline4\nline5\n" + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(body)), + } + + result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 3) + if respOut != nil && respOut.Body != nil { + defer respOut.Body.Close() + } + require.NoError(t, err) + assert.Equal(t, 5, totalLines) + assert.Equal(t, "line3\nline4\nline5", result) + }) + + t.Run("handles very long line exceeding 10MB", func(t *testing.T) { + // Create a line that exceeds maxLineSize (10MB) + longLine := strings.Repeat("x", 11*1024*1024) // 11MB + body := "line1\n" + longLine + "\nline3\n" + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(body)), + } + + result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 100) + if respOut != nil && respOut.Body != nil { + defer respOut.Body.Close() + } + require.NoError(t, err) + // Should have processed lines with truncation marker + assert.Greater(t, totalLines, 0) + assert.Contains(t, result, "TRUNCATED") + }) + + t.Run("handles line at exactly max size", func(t *testing.T) { + // Create a line just under maxLineSize + longLine := strings.Repeat("a", 1024*1024) // 1MB - should work fine + body := "start\n" + longLine + "\nend\n" + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(body)), + } + + result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 100) + if respOut != nil && respOut.Body != nil { + defer respOut.Body.Close() + } + require.NoError(t, err) + assert.Equal(t, 3, totalLines) + assert.Contains(t, result, "start") + assert.Contains(t, result, "end") + }) + + t.Run("ring buffer with long line in middle of many lines", func(t *testing.T) { + // Create many lines with a long line in the middle + // Ring buffer size is 5, so we should only keep the last 5 lines + var sb strings.Builder + for i := 1; i <= 10; i++ { + sb.WriteString(fmt.Sprintf("line%d\n", i)) + } + // Insert an 11MB line (exceeds maxLineSize of 10MB) + longLine := strings.Repeat("x", 11*1024*1024) + sb.WriteString(longLine) + sb.WriteString("\n") + for i := 11; i <= 20; i++ { + sb.WriteString(fmt.Sprintf("line%d\n", i)) + } + + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(sb.String())), + } + + result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 5) + if respOut != nil && respOut.Body != nil { + defer respOut.Body.Close() + } + require.NoError(t, err) + // 10 lines before + 1 long line + 10 lines after = 21 total + assert.Equal(t, 21, totalLines) + // Should only have the last 5 lines (line16 through line20) + assert.Contains(t, result, "line16") + assert.Contains(t, result, "line17") + assert.Contains(t, result, "line18") + assert.Contains(t, result, "line19") + assert.Contains(t, result, "line20") + // Should NOT contain earlier lines + assert.NotContains(t, result, "line1\n") + assert.NotContains(t, result, "line10\n") + // The truncated line should not be in the last 5 + assert.NotContains(t, result, "TRUNCATED") + }) + + t.Run("empty response body", func(t *testing.T) { + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader("")), + } + + result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 10) + if respOut != nil && respOut.Body != nil { + defer respOut.Body.Close() + } + require.NoError(t, err) + assert.Equal(t, 0, totalLines) + assert.Equal(t, "", result) + }) + + t.Run("line at exactly maxLineSize boundary", func(t *testing.T) { + // Create a line at exactly maxLineSize (10MB) - should be truncated + exactLine := strings.Repeat("z", 10*1024*1024) + body := "before\n" + exactLine + "\nafter\n" + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(body)), + } + + result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 10) + if respOut != nil && respOut.Body != nil { + defer respOut.Body.Close() + } + require.NoError(t, err) + assert.Equal(t, 3, totalLines) + assert.Contains(t, result, "before") + assert.Contains(t, result, "TRUNCATED") + assert.Contains(t, result, "after") + }) + + t.Run("ring buffer keeps truncated line when in last N", func(t *testing.T) { + // Long line followed by only 2 more lines, with ring buffer size 5 + longLine := strings.Repeat("y", 11*1024*1024) + body := "line1\nline2\nline3\n" + longLine + "\nlineA\nlineB\n" + resp := &http.Response{ + Body: io.NopCloser(strings.NewReader(body)), + } + + result, totalLines, respOut, err := ProcessResponseAsRingBufferToEnd(resp, 5) + if respOut != nil && respOut.Body != nil { + defer respOut.Body.Close() + } + require.NoError(t, err) + assert.Equal(t, 6, totalLines) + // Last 5: line2, line3, truncated, lineA, lineB + assert.Contains(t, result, "line2") + assert.Contains(t, result, "line3") + assert.Contains(t, result, "TRUNCATED") + assert.Contains(t, result, "lineA") + assert.Contains(t, result, "lineB") + // line1 should be rotated out + assert.NotContains(t, result, "line1") + }) +} diff --git a/pkg/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..7c1f28e660 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -6,7 +6,7 @@ import ( "net/http" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go index 072a09a289..7459569f2a 100644 --- a/pkg/errors/error_test.go +++ b/pkg/errors/error_test.go @@ -6,7 +6,7 @@ import ( "net/http" "testing" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/__toolsnaps__/actions_get.snap b/pkg/github/__toolsnaps__/actions_get.snap index b5f3b85bd3..ba128875ee 100644 --- a/pkg/github/__toolsnaps__/actions_get.snap +++ b/pkg/github/__toolsnaps__/actions_get.snap @@ -5,16 +5,8 @@ }, "description": "Get details about specific GitHub Actions resources.\nUse this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs.\n", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner", - "repo", - "resource_id" - ], "properties": { "method": { - "type": "string", "description": "The method to execute", "enum": [ "get_workflow", @@ -23,21 +15,29 @@ "download_workflow_run_artifact", "get_workflow_run_usage", "get_workflow_run_logs_url" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "resource_id": { - "type": "string", - "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method.\n- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods.\n- Provide an artifact ID for 'download_workflow_run_artifact' method.\n- Provide a job ID for 'get_workflow_job' method.\n" + "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method.\n- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods.\n- Provide an artifact ID for 'download_workflow_run_artifact' method.\n- Provide a job ID for 'get_workflow_job' method.\n", + "type": "string" } - } + }, + "required": [ + "method", + "owner", + "repo", + "resource_id" + ], + "type": "object" }, "name": "actions_get" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/actions_list.snap b/pkg/github/__toolsnaps__/actions_list.snap index 4bd0293880..a7e9ec56bd 100644 --- a/pkg/github/__toolsnaps__/actions_list.snap +++ b/pkg/github/__toolsnaps__/actions_list.snap @@ -5,68 +5,66 @@ }, "description": "Tools for listing GitHub Actions resources.\nUse this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run.\n", "inputSchema": { - "type": "object", "properties": { "method": { - "type": "string", "description": "The action to perform", "enum": [ "list_workflows", "list_workflow_runs", "list_workflow_jobs", "list_workflow_run_artifacts" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (default: 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "per_page": { - "type": "number", "description": "Results per page for pagination (default: 30, max: 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "resource_id": { - "type": "string", - "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Do not provide any resource ID for 'list_workflows' method.\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method, or omit to list all workflow runs in the repository.\n- Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods.\n" + "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Do not provide any resource ID for 'list_workflows' method.\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method, or omit to list all workflow runs in the repository.\n- Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods.\n", + "type": "string" }, "workflow_jobs_filter": { - "type": "object", + "description": "Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs'", "properties": { "filter": { - "type": "string", "description": "Filters jobs by their completed_at timestamp", "enum": [ "latest", "all" - ] + ], + "type": "string" } }, - "description": "Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs'" + "type": "object" }, "workflow_runs_filter": { - "type": "object", + "description": "Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs'", "properties": { "actor": { - "type": "string", - "description": "Filter to a specific GitHub user's workflow runs." + "description": "Filter to a specific GitHub user's workflow runs.", + "type": "string" }, "branch": { - "type": "string", - "description": "Filter workflow runs to a specific Git branch. Use the name of the branch." + "description": "Filter workflow runs to a specific Git branch. Use the name of the branch.", + "type": "string" }, "event": { - "type": "string", "description": "Filter workflow runs to a specific event type", "enum": [ "branch_protection_rule", @@ -101,10 +99,10 @@ "workflow_call", "workflow_dispatch", "workflow_run" - ] + ], + "type": "string" }, "status": { - "type": "string", "description": "Filter workflow runs to only runs with a specific status", "enum": [ "queued", @@ -112,17 +110,19 @@ "completed", "requested", "waiting" - ] + ], + "type": "string" } }, - "description": "Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs'" + "type": "object" } }, "required": [ "method", "owner", "repo" - ] + ], + "type": "object" }, "name": "actions_list" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/actions_run_trigger.snap b/pkg/github/__toolsnaps__/actions_run_trigger.snap index 4e16f89581..41a6439929 100644 --- a/pkg/github/__toolsnaps__/actions_run_trigger.snap +++ b/pkg/github/__toolsnaps__/actions_run_trigger.snap @@ -5,19 +5,13 @@ }, "description": "Trigger GitHub Actions workflow operations, including running, re-running, cancelling workflow runs, and deleting workflow run logs.", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner", - "repo" - ], "properties": { "inputs": { - "type": "object", - "description": "Inputs the workflow accepts. Only used for 'run_workflow' method." + "description": "Inputs the workflow accepts. Only used for 'run_workflow' method.", + "properties": {}, + "type": "object" }, "method": { - "type": "string", "description": "The method to execute", "enum": [ "run_workflow", @@ -25,29 +19,36 @@ "rerun_failed_jobs", "cancel_workflow_run", "delete_workflow_run_logs" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "ref": { - "type": "string", - "description": "The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method." + "description": "The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method.", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "run_id": { - "type": "number", - "description": "The ID of the workflow run. Required for all methods except 'run_workflow'." + "description": "The ID of the workflow run. Required for all methods except 'run_workflow'.", + "type": "number" }, "workflow_id": { - "type": "string", - "description": "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method." + "description": "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method.", + "type": "string" } - } + }, + "required": [ + "method", + "owner", + "repo" + ], + "type": "object" }, "name": "actions_run_trigger" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap b/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap index 78795c0961..af4c41f52f 100644 --- a/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap +++ b/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap @@ -4,69 +4,69 @@ }, "description": "Add review comment to the requester's latest pending pull request review. A pending review needs to already exist to call this (check with the user if not sure).", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "pullNumber", - "path", - "body", - "subjectType" - ], "properties": { "body": { - "type": "string", - "description": "The text of the review comment" + "description": "The text of the review comment", + "type": "string" }, "line": { - "type": "number", - "description": "The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range" + "description": "The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range", + "type": "number" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "path": { - "type": "string", - "description": "The relative path to the file that necessitates a comment" + "description": "The relative path to the file that necessitates a comment", + "type": "string" }, "pullNumber": { - "type": "number", - "description": "Pull request number" + "description": "Pull request number", + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "side": { - "type": "string", "description": "The side of the diff to comment on. LEFT indicates the previous state, RIGHT indicates the new state", "enum": [ "LEFT", "RIGHT" - ] + ], + "type": "string" }, "startLine": { - "type": "number", - "description": "For multi-line comments, the first line of the range that the comment applies to" + "description": "For multi-line comments, the first line of the range that the comment applies to", + "type": "number" }, "startSide": { - "type": "string", "description": "For multi-line comments, the starting side of the diff that the comment applies to. LEFT indicates the previous state, RIGHT indicates the new state", "enum": [ "LEFT", "RIGHT" - ] + ], + "type": "string" }, "subjectType": { - "type": "string", "description": "The level at which the comment is targeted", "enum": [ "FILE", "LINE" - ] + ], + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "pullNumber", + "path", + "body", + "subjectType" + ], + "type": "object" }, "name": "add_comment_to_pending_review" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_issue_comment.snap b/pkg/github/__toolsnaps__/add_issue_comment.snap index fb2a9e7b39..d273a582d6 100644 --- a/pkg/github/__toolsnaps__/add_issue_comment.snap +++ b/pkg/github/__toolsnaps__/add_issue_comment.snap @@ -4,31 +4,31 @@ }, "description": "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "issue_number", - "body" - ], "properties": { "body": { - "type": "string", - "description": "Comment content" + "description": "Comment content", + "type": "string" }, "issue_number": { - "type": "number", - "description": "Issue number to comment on" + "description": "Issue number to comment on", + "type": "number" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "issue_number", + "body" + ], + "type": "object" }, "name": "add_issue_comment" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_project_item.snap b/pkg/github/__toolsnaps__/add_project_item.snap deleted file mode 100644 index 08f4953706..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": { - "type": "object", - "required": [ - "owner_type", - "owner", - "project_number", - "item_type", - "item_id" - ], - "properties": { - "item_id": { - "type": "number", - "description": "The numeric ID of the issue or pull request to add to the project." - }, - "item_type": { - "type": "string", - "description": "The item's type, either issue or pull_request.", - "enum": [ - "issue", - "pull_request" - ] - }, - "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." - }, - "owner_type": { - "type": "string", - "description": "Owner type", - "enum": [ - "user", - "org" - ] - }, - "project_number": { - "type": "number", - "description": "The project's number." - } - } - }, - "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__/assign_copilot_to_issue.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap index 354600147b..9c105267bc 100644 --- a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap +++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap @@ -4,39 +4,47 @@ "title": "Assign Copilot to issue" }, "description": "Assign Copilot to a specific issue in a GitHub repository.\n\nThis tool can help with the following outcomes:\n- a Pull Request created with source code changes to resolve the issue\n\n\nMore information can be found at:\n- https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot\n", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC20lEQVRIidWUS4wMURSGv3O7kWmPEMRrSMzcbl1dpqtmGuOxsCKECCKxEBusSJhIWEhsWLFAbC1sWFiISBARCyQ2kzSZGaMxHokgXvGIiMH0PRZjpJqqHpb+TeX+59z//H/q5sD/DqlX9H1/zFeX2qzIKoFWYDKgwBtUymL0UkNaT3V3d3/+5wG2EGxB9TDIxGFMvhVhb9/drpN/NaDJC7MGdwJk6TDCv0Gvq0lve9R762GUNdFDLleaZNBrICGq+4yhvf9TJtP/KZNB2PrLlbBliBfRhajuAwnFVa/n8/nkxFkv3GO9oJrzgwVxdesV71ov6I2r5fxggfWCatYL9yYmUJgLPH7Q29WZ4OED6Me4wuAdeQK6MMqna9t0GuibBHFAmgZ9JMG9BhkXZWoSCDSATIq7aguBD0wBplq/tZBgYDIwKnZAs99mFRYD9vd/YK0dpcqhobM6d9haWyOULRTbAauwuNlvsxHTYP3iBnVyXGAa8BIYC3oVeAKioCtAPEE7FCOgR0ErIJdBBZgNskzh40+NF6K6s+9e91lp9osrxMnFoTSmSmPVsF+E5cB0YEDgtoMjjypd5wCy+WC9GnajhEAa4bkqV9LOHKwa9/yneYeyUqwX3AdyQ5EeVrrqro/hYL0g+ggemKh4HGbPmVu0+fB8U76lpR6XgJwZpoGUpNYiusZg1tXjkmCAav0OMTXfJC4eVYPqwbot6l4BCPqyLhd7lwMAWC/cYb3gi/UCzRaKOxsbFzVEM1iv2Ebt5v2Dm14qZbJecZf1Ah3UCrcTbbB+awHnjgHLgHeinHYqZ8aPSXWWy+XvcQZLpdKI9/0D7UbZiLIJmABckVSqo+/OrUrNgF+D8q1LEdcBrAJGAJ8ROlGeicorABWdAswE5gOjge8CF8Ad66v03IjqJb75WS0tE0YOmNWqLBGReaAzgIkMLrt3oM9UpSzCzW9pd+FpT8/7JK3/Gz8Ao5X6wtwP7N4AAAAASUVORK5CYII=", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACCElEQVRIid2UPWsUYRSFn3dxWWJUkESiBgslFokfhehGiGClBBQx4h9IGlEh2ijYxh+gxEL/hIWwhYpF8KNZsFRJYdJEiUbjCkqisj4W+y6Mk5nd1U4PDMOce+45L3fmDvzXUDeo59WK+kb9rn5TF9R76jm1+2/NJ9QPtseSOv4nxrvVmQ6M05hRB9qZ98ZR1NRralntitdEwmw8wQ9HbS329rQKuKLW1XJO/aX6IqdWjr1Xk/y6lG4vMBdCqOacoZZ3uBBCVZ0HDrcK2AYs5ZkAuwBb1N8Dm5JEISXoAnqzOtU9QB+wVR3KCdgClDIr6kCc4c/0O1BLNnahiYpaSmmGY62e/JpCLJ4FpmmMaBHYCDwC5mmMZBQYBC7HnhvAK+B+fN4JHAM+R4+3wGQI4S7qaExtol+9o86pq+oX9Yk6ljjtGfVprK2qr9Xb6vaET109jjqb3Jac2XaM1PLNpok1Aep+G/+dfa24nADTX1EWTgOngLE2XCYKQL0DTfKex2WhXgCutxG9i/fFNlwWpgBQL6orcWyTaldToRbUA2pow61XL0WPFfXCb1HqkPowCj6q0+qIWsw7nlpUj6i31OXY+0AdbGpCRtNRGgt1AigCX4EqsJAYTR+wAzgEdAM/gApwM4TwOOm3JiARtBk4CYwAB4F+oIfGZi/HwOfAM6ASQviU5/Vv4xcBzmW2eT1nrQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { - "type": "object", "properties": { + "base_ref": { + "description": "Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch", + "type": "string" + }, + "custom_instructions": { + "description": "Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description", + "type": "string" + }, "issue_number": { - "type": "number", - "description": "Issue number" + "description": "Issue number", + "type": "number" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } }, "required": [ "owner", "repo", "issue_number" - ] + ], + "type": "object" }, - "name": "assign_copilot_to_issue", - "icons": [ - { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC20lEQVRIidWUS4wMURSGv3O7kWmPEMRrSMzcbl1dpqtmGuOxsCKECCKxEBusSJhIWEhsWLFAbC1sWFiISBARCyQ2kzSZGaMxHokgXvGIiMH0PRZjpJqqHpb+TeX+59z//H/q5sD/DqlX9H1/zFeX2qzIKoFWYDKgwBtUymL0UkNaT3V3d3/+5wG2EGxB9TDIxGFMvhVhb9/drpN/NaDJC7MGdwJk6TDCv0Gvq0lve9R762GUNdFDLleaZNBrICGq+4yhvf9TJtP/KZNB2PrLlbBliBfRhajuAwnFVa/n8/nkxFkv3GO9oJrzgwVxdesV71ov6I2r5fxggfWCatYL9yYmUJgLPH7Q29WZ4OED6Me4wuAdeQK6MMqna9t0GuibBHFAmgZ9JMG9BhkXZWoSCDSATIq7aguBD0wBplq/tZBgYDIwKnZAs99mFRYD9vd/YK0dpcqhobM6d9haWyOULRTbAauwuNlvsxHTYP3iBnVyXGAa8BIYC3oVeAKioCtAPEE7FCOgR0ErIJdBBZgNskzh40+NF6K6s+9e91lp9osrxMnFoTSmSmPVsF+E5cB0YEDgtoMjjypd5wCy+WC9GnajhEAa4bkqV9LOHKwa9/yneYeyUqwX3AdyQ5EeVrrqro/hYL0g+ggemKh4HGbPmVu0+fB8U76lpR6XgJwZpoGUpNYiusZg1tXjkmCAav0OMTXfJC4eVYPqwbot6l4BCPqyLhd7lwMAWC/cYb3gi/UCzRaKOxsbFzVEM1iv2Ebt5v2Dm14qZbJecZf1Ah3UCrcTbbB+awHnjgHLgHeinHYqZ8aPSXWWy+XvcQZLpdKI9/0D7UbZiLIJmABckVSqo+/OrUrNgF+D8q1LEdcBrAJGAJ8ROlGeicorABWdAswE5gOjge8CF8Ad66v03IjqJb75WS0tE0YOmNWqLBGReaAzgIkMLrt3oM9UpSzCzW9pd+FpT8/7JK3/Gz8Ao5X6wtwP7N4AAAAASUVORK5CYII=", - "mimeType": "image/png", - "theme": "light" - }, - { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACCElEQVRIid2UPWsUYRSFn3dxWWJUkESiBgslFokfhehGiGClBBQx4h9IGlEh2ijYxh+gxEL/hIWwhYpF8KNZsFRJYdJEiUbjCkqisj4W+y6Mk5nd1U4PDMOce+45L3fmDvzXUDeo59WK+kb9rn5TF9R76jm1+2/NJ9QPtseSOv4nxrvVmQ6M05hRB9qZ98ZR1NRralntitdEwmw8wQ9HbS329rQKuKLW1XJO/aX6IqdWjr1Xk/y6lG4vMBdCqOacoZZ3uBBCVZ0HDrcK2AYs5ZkAuwBb1N8Dm5JEISXoAnqzOtU9QB+wVR3KCdgClDIr6kCc4c/0O1BLNnahiYpaSmmGY62e/JpCLJ4FpmmMaBHYCDwC5mmMZBQYBC7HnhvAK+B+fN4JHAM+R4+3wGQI4S7qaExtol+9o86pq+oX9Yk6ljjtGfVprK2qr9Xb6vaET109jjqb3Jac2XaM1PLNpok1Aep+G/+dfa24nADTX1EWTgOngLE2XCYKQL0DTfKex2WhXgCutxG9i/fFNlwWpgBQL6orcWyTaldToRbUA2pow61XL0WPFfXCb1HqkPowCj6q0+qIWsw7nlpUj6i31OXY+0AdbGpCRtNRGgt1AigCX4EqsJAYTR+wAzgEdAM/gApwM4TwOOm3JiARtBk4CYwAB4F+oIfGZi/HwOfAM6ASQviU5/Vv4xcBzmW2eT1nrQAAAABJRU5ErkJggg==", - "mimeType": "image/png", - "theme": "dark" - } - ] + "name": "assign_copilot_to_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 83eb31a7f9..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": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "name": "cancel_workflow_run" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_branch.snap b/pkg/github/__toolsnaps__/create_branch.snap index 675a2de9c0..a561247ef1 100644 --- a/pkg/github/__toolsnaps__/create_branch.snap +++ b/pkg/github/__toolsnaps__/create_branch.snap @@ -4,30 +4,30 @@ }, "description": "Create a new branch in a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "branch" - ], "properties": { "branch": { - "type": "string", - "description": "Name for new branch" + "description": "Name for new branch", + "type": "string" }, "from_branch": { - "type": "string", - "description": "Source branch (defaults to repo default)" + "description": "Source branch (defaults to repo default)", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "branch" + ], + "type": "object" }, "name": "create_branch" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_gist.snap b/pkg/github/__toolsnaps__/create_gist.snap index 465206ab43..0ef05aa4a7 100644 --- a/pkg/github/__toolsnaps__/create_gist.snap +++ b/pkg/github/__toolsnaps__/create_gist.snap @@ -4,30 +4,30 @@ }, "description": "Create a new gist", "inputSchema": { - "type": "object", - "required": [ - "filename", - "content" - ], "properties": { "content": { - "type": "string", - "description": "Content for simple single-file gist creation" + "description": "Content for simple single-file gist creation", + "type": "string" }, "description": { - "type": "string", - "description": "Description of the gist" + "description": "Description of the gist", + "type": "string" }, "filename": { - "type": "string", - "description": "Filename for simple single-file gist creation" + "description": "Filename for simple single-file gist creation", + "type": "string" }, "public": { - "type": "boolean", + "default": false, "description": "Whether the gist is public", - "default": false + "type": "boolean" } - } + }, + "required": [ + "filename", + "content" + ], + "type": "object" }, "name": "create_gist" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_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 2d9ae1144f..e6900c9053 100644 --- a/pkg/github/__toolsnaps__/create_or_update_file.snap +++ b/pkg/github/__toolsnaps__/create_or_update_file.snap @@ -2,47 +2,47 @@ "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": { - "type": "object", - "required": [ - "owner", - "repo", - "path", - "content", - "message", - "branch" - ], "properties": { "branch": { - "type": "string", - "description": "Branch to create/update the file in" + "description": "Branch to create/update the file in", + "type": "string" }, "content": { - "type": "string", - "description": "Content of the file" + "description": "Content of the file", + "type": "string" }, "message": { - "type": "string", - "description": "Commit message" + "description": "Commit message", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner (username or organization)" + "description": "Repository owner (username or organization)", + "type": "string" }, "path": { - "type": "string", - "description": "Path where to create/update the file" + "description": "Path where to create/update the file", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "sha": { - "type": "string", - "description": "The blob SHA of the file being replaced." + "description": "The blob SHA of the file being replaced. Required if the file already exists.", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "path", + "content", + "message", + "branch" + ], + "type": "object" }, "name": "create_or_update_file" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_pull_request.snap b/pkg/github/__toolsnaps__/create_pull_request.snap index 80f0b98633..a8a94ce690 100644 --- a/pkg/github/__toolsnaps__/create_pull_request.snap +++ b/pkg/github/__toolsnaps__/create_pull_request.snap @@ -1,51 +1,60 @@ { + "_meta": { + "ui": { + "resourceUri": "ui://github-mcp-server/pr-write", + "visibility": [ + "model", + "app" + ] + } + }, "annotations": { "title": "Open new pull request" }, "description": "Create a new pull request in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "title", - "head", - "base" - ], "properties": { "base": { - "type": "string", - "description": "Branch to merge into" + "description": "Branch to merge into", + "type": "string" }, "body": { - "type": "string", - "description": "PR description" + "description": "PR description", + "type": "string" }, "draft": { - "type": "boolean", - "description": "Create as draft PR" + "description": "Create as draft PR", + "type": "boolean" }, "head": { - "type": "string", - "description": "Branch containing changes" + "description": "Branch containing changes", + "type": "string" }, "maintainer_can_modify": { - "type": "boolean", - "description": "Allow maintainer edits" + "description": "Allow maintainer edits", + "type": "boolean" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "title": { - "type": "string", - "description": "PR title" + "description": "PR title", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "title", + "head", + "base" + ], + "type": "object" }, "name": "create_pull_request" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_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 290767c667..2cc4227b23 100644 --- a/pkg/github/__toolsnaps__/create_repository.snap +++ b/pkg/github/__toolsnaps__/create_repository.snap @@ -4,32 +4,32 @@ }, "description": "Create a new GitHub repository in your account or specified organization", "inputSchema": { - "type": "object", - "required": [ - "name" - ], "properties": { "autoInit": { - "type": "boolean", - "description": "Initialize with README" + "description": "Initialize with README", + "type": "boolean" }, "description": { - "type": "string", - "description": "Repository description" + "description": "Repository description", + "type": "string" }, "name": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "organization": { - "type": "string", - "description": "Organization to create the repository in (omit to create in your personal account)" + "description": "Organization to create the repository in (omit to create in your personal account)", + "type": "string" }, "private": { - "type": "boolean", - "description": "Whether repo should be private" + "description": "Whether repo should be private", + "type": "boolean" } - } + }, + "required": [ + "name" + ], + "type": "object" }, "name": "create_repository" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_file.snap b/pkg/github/__toolsnaps__/delete_file.snap index b985154e86..ff110ff786 100644 --- a/pkg/github/__toolsnaps__/delete_file.snap +++ b/pkg/github/__toolsnaps__/delete_file.snap @@ -5,36 +5,36 @@ }, "description": "Delete a file from a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "path", - "message", - "branch" - ], "properties": { "branch": { - "type": "string", - "description": "Branch to delete the file from" + "description": "Branch to delete the file from", + "type": "string" }, "message": { - "type": "string", - "description": "Commit message" + "description": "Commit message", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner (username or organization)" + "description": "Repository owner (username or organization)", + "type": "string" }, "path": { - "type": "string", - "description": "Path to the file to delete" + "description": "Path to the file to delete", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "path", + "message", + "branch" + ], + "type": "object" }, "name": "delete_file" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_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 430c83cc86..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": { - "type": "object", - "required": [ - "owner_type", - "owner", - "project_number", - "item_id" - ], - "properties": { - "item_id": { - "type": "number", - "description": "The internal project item ID to delete from the project (not the issue or pull request ID)." - }, - "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." - }, - "owner_type": { - "type": "string", - "description": "Owner type", - "enum": [ - "user", - "org" - ] - }, - "project_number": { - "type": "number", - "description": "The project's number." - } - } - }, - "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 fc9a5cd46c..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": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "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__/dismiss_notification.snap b/pkg/github/__toolsnaps__/dismiss_notification.snap index b0125ba536..6c65d9ce05 100644 --- a/pkg/github/__toolsnaps__/dismiss_notification.snap +++ b/pkg/github/__toolsnaps__/dismiss_notification.snap @@ -4,25 +4,25 @@ }, "description": "Dismiss a notification by marking it as read or done", "inputSchema": { - "type": "object", - "required": [ - "threadID", - "state" - ], "properties": { "state": { - "type": "string", "description": "The new state of the notification (read/done)", "enum": [ "read", "done" - ] + ], + "type": "string" }, "threadID": { - "type": "string", - "description": "The ID of the notification thread" + "description": "The ID of the notification thread", + "type": "string" } - } + }, + "required": [ + "threadID", + "state" + ], + "type": "object" }, "name": "dismiss_notification" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/download_workflow_run_artifact.snap b/pkg/github/__toolsnaps__/download_workflow_run_artifact.snap deleted file mode 100644 index c4d89872ce..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": { - "type": "object", - "required": [ - "owner", - "repo", - "artifact_id" - ], - "properties": { - "artifact_id": { - "type": "number", - "description": "The unique identifier of the artifact" - }, - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - } - } - }, - "name": "download_workflow_run_artifact" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/fork_repository.snap b/pkg/github/__toolsnaps__/fork_repository.snap index 18525a4f72..d635734a95 100644 --- a/pkg/github/__toolsnaps__/fork_repository.snap +++ b/pkg/github/__toolsnaps__/fork_repository.snap @@ -3,38 +3,38 @@ "title": "Fork repository" }, "description": "Fork a GitHub repository to your account or specified organization", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], - "properties": { - "organization": { - "type": "string", - "description": "Organization to fork to" - }, - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - } - } - }, - "name": "fork_repository", "icons": [ { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACuElEQVRIibWTTUhUYRiFn/fOdYyoydQxk4LEGzN3RudaLYL+qRaBQYsIItoHCW37ISNbRwUFLWoRZBEt+4EIooKoTdZQ6TWaNIgouzJkuGhG731b6JTojDNBntX3ne+c97zfH8wzZCbREm9bZ4hsQvkeDvl3+/r6xuYqEIvFFgdSvRuDqCrPMu6bVyUDrITTjdI1jR8KBbrj/fs3Q8WLp5p9Qx4BzVOUInIm058+XdAY0ztH6RLhSpAza1RlI2jENzhfqntfjAugEdTYMFEtS0GvonrKslNrZwWIhDYDMh6Wo4ODvaMfB9LPFaMHZGvJ8xHdAlzPDLx+8Smd/pE39SggAptnB2gwDBD6ReJvhSCpMFyq/uSa/NFX5UMJgGCaxywMwiH/bi4wh0SCOy1x5waiCUF2gnSW3AByEfSSZTsPVXFF9CDC4ALx7xU0ocLA87x8tG7ZHRUShsheVMKInMy46culArIj317WRpd7KB2GsAl4bKoccN2330t5ALBsJ7ASTvecoun6hNNt2U5QbM0oRip8E6Wt0gCUFPC12FKoGFnX0BgBDtVGG3/W1qzqz2a/5IrpLGt9pLahvhPhCKrnsiPDT2dqZv1kgGQyGc4FZg+wr8I93F6y0DzY29s7XlHAnw7j7dswgg2oRCYZPTBluzk51VEwXmQG0k8qbGRuWHbqiWWn/qlY0Uv+n5j3gKKvaCaSyeSimrqms4hsB4kurW9c0bSs/pnneflyXrOcACCn5jWEPSr0AAgczvlVTVT+ykojFlvTZNmOWvHU8QJnJVInLNtR2163vJy/7B0EpjYAqBhugVMVF8A3goZy/rJHFGa8P4fpCXosHm9PqwbiwzHAqyLvlvPP+dEKWG23dyh6C1g0RY0Jsv+Dm77/XwIAWlpbVzJh7gLAnHjw8d27z5V65xW/AVGM6Ekx9nZCAAAAAElFTkSuQmCC", "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACuElEQVRIibWTTUhUYRiFn/fOdYyoydQxk4LEGzN3RudaLYL+qRaBQYsIItoHCW37ISNbRwUFLWoRZBEt+4EIooKoTdZQ6TWaNIgouzJkuGhG731b6JTojDNBntX3ne+c97zfH8wzZCbREm9bZ4hsQvkeDvl3+/r6xuYqEIvFFgdSvRuDqCrPMu6bVyUDrITTjdI1jR8KBbrj/fs3Q8WLp5p9Qx4BzVOUInIm058+XdAY0ztH6RLhSpAza1RlI2jENzhfqntfjAugEdTYMFEtS0GvonrKslNrZwWIhDYDMh6Wo4ODvaMfB9LPFaMHZGvJ8xHdAlzPDLx+8Smd/pE39SggAptnB2gwDBD6ReJvhSCpMFyq/uSa/NFX5UMJgGCaxywMwiH/bi4wh0SCOy1x5waiCUF2gnSW3AByEfSSZTsPVXFF9CDC4ALx7xU0ocLA87x8tG7ZHRUShsheVMKInMy46culArIj317WRpd7KB2GsAl4bKoccN2330t5ALBsJ7ASTvecoun6hNNt2U5QbM0oRip8E6Wt0gCUFPC12FKoGFnX0BgBDtVGG3/W1qzqz2a/5IrpLGt9pLahvhPhCKrnsiPDT2dqZv1kgGQyGc4FZg+wr8I93F6y0DzY29s7XlHAnw7j7dswgg2oRCYZPTBluzk51VEwXmQG0k8qbGRuWHbqiWWn/qlY0Uv+n5j3gKKvaCaSyeSimrqms4hsB4kurW9c0bSs/pnneflyXrOcACCn5jWEPSr0AAgczvlVTVT+ykojFlvTZNmOWvHU8QJnJVInLNtR2163vJy/7B0EpjYAqBhugVMVF8A3goZy/rJHFGa8P4fpCXosHm9PqwbiwzHAqyLvlvPP+dEKWG23dyh6C1g0RY0Jsv+Dm77/XwIAWlpbVzJh7gLAnHjw8d27z5V65xW/AVGM6Ekx9nZCAAAAAElFTkSuQmCC", "theme": "light" }, { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABoUlEQVRIibWUPS+EQRSFz0hsxaIgIZHYllDYiiiUEn6Bn0Dho6Nhf4GPjkYn8ZEgGqFRSNBoVTQKdhEROsk+mrt2svvu7Gutk7yZzJlz77nzztyR/hmulADSkkYk5SQdO+c+QwmAZkkTktolXTjnbkLiDJCniHsgFdCnTFNAHliuJE6bYANoAYaBF+AwYHBkmiGgFdi0HINR4lmrotXjVoG3gMEbsOLN2yzHTIFr8PRZG3s9rs/jo5At0fd6fFk1TfY/X4A14MyqmQrsYNo0pxbzCtwBTZUCUsAh8GHCKaDspnl6ZyZ3FnMA9AR2/BOYBzJVhUV9BshHrTVEkZKeJPXHNZA0IOkxttrrhzkgGdAlgXnTLv3GIAHsEh87QGNUrooHaEajkoYlFXYxaeO2je+SLp1z57Grr2J4DvwqWaVDrhv+3SAWrMvXgWcgZ10b3a01GuwDX8CWfV/AXr2Sd9lVXPC4ReM6q8XHOYMOG2897rZkrXZY0+WAK6DHHsRr4xJ/NjCTcXstC/gAxuPEBju5xKRb0phNT5xzD7UUW3d8A4p92DZKdSwEAAAAAElFTkSuQmCC", "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABoUlEQVRIibWUPS+EQRSFz0hsxaIgIZHYllDYiiiUEn6Bn0Dho6Nhf4GPjkYn8ZEgGqFRSNBoVTQKdhEROsk+mrt2svvu7Gutk7yZzJlz77nzztyR/hmulADSkkYk5SQdO+c+QwmAZkkTktolXTjnbkLiDJCniHsgFdCnTFNAHliuJE6bYANoAYaBF+AwYHBkmiGgFdi0HINR4lmrotXjVoG3gMEbsOLN2yzHTIFr8PRZG3s9rs/jo5At0fd6fFk1TfY/X4A14MyqmQrsYNo0pxbzCtwBTZUCUsAh8GHCKaDspnl6ZyZ3FnMA9AR2/BOYBzJVhUV9BshHrTVEkZKeJPXHNZA0IOkxttrrhzkgGdAlgXnTLv3GIAHsEh87QGNUrooHaEajkoYlFXYxaeO2je+SLp1z57Grr2J4DvwqWaVDrhv+3SAWrMvXgWcgZ10b3a01GuwDX8CWfV/AXr2Sd9lVXPC4ReM6q8XHOYMOG2897rZkrXZY0+WAK6DHHsRr4xJ/NjCTcXstC/gAxuPEBju5xKRb0phNT5xzD7UUW3d8A4p92DZKdSwEAAAAAElFTkSuQmCC", "theme": "dark" } - ] + ], + "inputSchema": { + "properties": { + "organization": { + "description": "Organization to fork to", + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "fork_repository" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_code_scanning_alert.snap b/pkg/github/__toolsnaps__/get_code_scanning_alert.snap index 9e46b960a3..2a65aefa64 100644 --- a/pkg/github/__toolsnaps__/get_code_scanning_alert.snap +++ b/pkg/github/__toolsnaps__/get_code_scanning_alert.snap @@ -5,26 +5,26 @@ }, "description": "Get details of a specific code scanning alert in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "alertNumber" - ], "properties": { "alertNumber": { - "type": "number", - "description": "The number of the alert." + "description": "The number of the alert.", + "type": "number" }, "owner": { - "type": "string", - "description": "The owner of the repository." + "description": "The owner of the repository.", + "type": "string" }, "repo": { - "type": "string", - "description": "The name of the repository." + "description": "The name of the repository.", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "alertNumber" + ], + "type": "object" }, "name": "get_code_scanning_alert" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_commit.snap b/pkg/github/__toolsnaps__/get_commit.snap index c6b96d5ed9..9e2346b59d 100644 --- a/pkg/github/__toolsnaps__/get_commit.snap +++ b/pkg/github/__toolsnaps__/get_commit.snap @@ -5,42 +5,42 @@ }, "description": "Get details for a commit from a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "sha" - ], "properties": { "include_diff": { - "type": "boolean", + "default": true, "description": "Whether to include file diffs and stats in the response. Default is true.", - "default": true + "type": "boolean" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "sha": { - "type": "string", - "description": "Commit SHA, branch name, or tag name" + "description": "Commit SHA, branch name, or tag name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "sha" + ], + "type": "object" }, "name": "get_commit" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_dependabot_alert.snap b/pkg/github/__toolsnaps__/get_dependabot_alert.snap index a517809e2b..78ff827d25 100644 --- a/pkg/github/__toolsnaps__/get_dependabot_alert.snap +++ b/pkg/github/__toolsnaps__/get_dependabot_alert.snap @@ -5,26 +5,26 @@ }, "description": "Get details of a specific dependabot alert in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "alertNumber" - ], "properties": { "alertNumber": { - "type": "number", - "description": "The number of the alert." + "description": "The number of the alert.", + "type": "number" }, "owner": { - "type": "string", - "description": "The owner of the repository." + "description": "The owner of the repository.", + "type": "string" }, "repo": { - "type": "string", - "description": "The name of the repository." + "description": "The name of the repository.", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "alertNumber" + ], + "type": "object" }, "name": "get_dependabot_alert" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_discussion.snap b/pkg/github/__toolsnaps__/get_discussion.snap index feef0f0575..b931afe79a 100644 --- a/pkg/github/__toolsnaps__/get_discussion.snap +++ b/pkg/github/__toolsnaps__/get_discussion.snap @@ -5,26 +5,26 @@ }, "description": "Get a specific discussion by ID", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "discussionNumber" - ], "properties": { "discussionNumber": { - "type": "number", - "description": "Discussion Number" + "description": "Discussion Number", + "type": "number" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "discussionNumber" + ], + "type": "object" }, "name": "get_discussion" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_discussion_comments.snap b/pkg/github/__toolsnaps__/get_discussion_comments.snap index 3af5edc8ce..422fc40bf7 100644 --- a/pkg/github/__toolsnaps__/get_discussion_comments.snap +++ b/pkg/github/__toolsnaps__/get_discussion_comments.snap @@ -5,36 +5,40 @@ }, "description": "Get comments from a discussion", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "discussionNumber" - ], "properties": { "after": { - "type": "string", - "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs." + "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + "type": "string" }, "discussionNumber": { - "type": "number", - "description": "Discussion Number" + "description": "Discussion Number", + "type": "number" + }, + "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": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "discussionNumber" + ], + "type": "object" }, "name": "get_discussion_comments" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_file_contents.snap b/pkg/github/__toolsnaps__/get_file_contents.snap index 638452fe7b..94b7aeedac 100644 --- a/pkg/github/__toolsnaps__/get_file_contents.snap +++ b/pkg/github/__toolsnaps__/get_file_contents.snap @@ -5,34 +5,34 @@ }, "description": "Get the contents of a file or directory from a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner (username or organization)" + "description": "Repository owner (username or organization)", + "type": "string" }, "path": { - "type": "string", + "default": "/", "description": "Path to file/directory", - "default": "/" + "type": "string" }, "ref": { - "type": "string", - "description": "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`" + "description": "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "sha": { - "type": "string", - "description": "Accepts optional commit SHA. If specified, it will be used instead of ref" + "description": "Accepts optional commit SHA. If specified, it will be used instead of ref", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "get_file_contents" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_gist.snap b/pkg/github/__toolsnaps__/get_gist.snap index 4d26618221..ef316937fb 100644 --- a/pkg/github/__toolsnaps__/get_gist.snap +++ b/pkg/github/__toolsnaps__/get_gist.snap @@ -5,16 +5,16 @@ }, "description": "Get gist content of a particular gist, by gist ID", "inputSchema": { - "type": "object", - "required": [ - "gist_id" - ], "properties": { "gist_id": { - "type": "string", - "description": "The ID of the gist" + "description": "The ID of the gist", + "type": "string" } - } + }, + "required": [ + "gist_id" + ], + "type": "object" }, "name": "get_gist" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_global_security_advisory.snap b/pkg/github/__toolsnaps__/get_global_security_advisory.snap index 18c30425a8..97b81d17dd 100644 --- a/pkg/github/__toolsnaps__/get_global_security_advisory.snap +++ b/pkg/github/__toolsnaps__/get_global_security_advisory.snap @@ -5,16 +5,16 @@ }, "description": "Get a global security advisory", "inputSchema": { - "type": "object", - "required": [ - "ghsaId" - ], "properties": { "ghsaId": { - "type": "string", - "description": "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)." + "description": "GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", + "type": "string" } - } + }, + "required": [ + "ghsaId" + ], + "type": "object" }, "name": "get_global_security_advisory" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_job_logs.snap b/pkg/github/__toolsnaps__/get_job_logs.snap index 8b2319527d..575182c0b1 100644 --- a/pkg/github/__toolsnaps__/get_job_logs.snap +++ b/pkg/github/__toolsnaps__/get_job_logs.snap @@ -5,42 +5,42 @@ }, "description": "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "failed_only": { - "type": "boolean", - "description": "When true, gets logs for all failed jobs in run_id" + "description": "When true, gets logs for all failed jobs in run_id", + "type": "boolean" }, "job_id": { - "type": "number", - "description": "The unique identifier of the workflow job (required for single job logs)" + "description": "The unique identifier of the workflow job (required for single job logs)", + "type": "number" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "return_content": { - "type": "boolean", - "description": "Returns actual log content instead of URLs" + "description": "Returns actual log content instead of URLs", + "type": "boolean" }, "run_id": { - "type": "number", - "description": "Workflow run ID (required when using failed_only)" + "description": "Workflow run ID (required when using failed_only)", + "type": "number" }, "tail_lines": { - "type": "number", + "default": 500, "description": "Number of lines to return from the end of the log", - "default": 500 + "type": "number" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "get_job_logs" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_label.snap b/pkg/github/__toolsnaps__/get_label.snap index 8541044d03..379ca7d8df 100644 --- a/pkg/github/__toolsnaps__/get_label.snap +++ b/pkg/github/__toolsnaps__/get_label.snap @@ -1,30 +1,30 @@ { "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": { - "type": "object", - "required": [ - "owner", - "repo", - "name" - ], "properties": { "name": { - "type": "string", - "description": "Label name." + "description": "Label name.", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner (username or organization name)" + "description": "Repository owner (username or organization name)", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "name" + ], + "type": "object" }, "name": "get_label" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_latest_release.snap b/pkg/github/__toolsnaps__/get_latest_release.snap index 23b551a0f6..760d8f812b 100644 --- a/pkg/github/__toolsnaps__/get_latest_release.snap +++ b/pkg/github/__toolsnaps__/get_latest_release.snap @@ -5,21 +5,21 @@ }, "description": "Get the latest release in a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "get_latest_release" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_me.snap b/pkg/github/__toolsnaps__/get_me.snap index e6d02929fa..6f287df092 100644 --- a/pkg/github/__toolsnaps__/get_me.snap +++ b/pkg/github/__toolsnaps__/get_me.snap @@ -1,12 +1,21 @@ { + "_meta": { + "ui": { + "resourceUri": "ui://github-mcp-server/get-me", + "visibility": [ + "model", + "app" + ] + } + }, "annotations": { "readOnlyHint": true, "title": "Get my user profile" }, "description": "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls.", "inputSchema": { - "type": "object", - "properties": {} + "properties": {}, + "type": "object" }, "name": "get_me" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_notification_details.snap b/pkg/github/__toolsnaps__/get_notification_details.snap index de197f2b14..48842229ff 100644 --- a/pkg/github/__toolsnaps__/get_notification_details.snap +++ b/pkg/github/__toolsnaps__/get_notification_details.snap @@ -5,16 +5,16 @@ }, "description": "Get detailed information for a specific GitHub notification, always call this tool when the user asks for details about a specific notification, if you don't know the ID list notifications first.", "inputSchema": { - "type": "object", - "required": [ - "notificationID" - ], "properties": { "notificationID": { - "type": "string", - "description": "The ID of the notification" + "description": "The ID of the notification", + "type": "string" } - } + }, + "required": [ + "notificationID" + ], + "type": "object" }, "name": "get_notification_details" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_project.snap b/pkg/github/__toolsnaps__/get_project.snap deleted file mode 100644 index 8194b7358e..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": { - "type": "object", - "required": [ - "project_number", - "owner_type", - "owner" - ], - "properties": { - "owner": { - "type": "string", - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." - }, - "owner_type": { - "type": "string", - "description": "Owner type", - "enum": [ - "user", - "org" - ] - }, - "project_number": { - "type": "number", - "description": "The project's number" - } - } - }, - "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 0df557a032..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": { - "type": "object", - "required": [ - "owner_type", - "owner", - "project_number", - "field_id" - ], - "properties": { - "field_id": { - "type": "number", - "description": "The field's id." - }, - "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." - }, - "owner_type": { - "type": "string", - "description": "Owner type", - "enum": [ - "user", - "org" - ] - }, - "project_number": { - "type": "number", - "description": "The project's number." - } - } - }, - "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 d77c49c1ef..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": { - "type": "object", - "required": [ - "owner_type", - "owner", - "project_number", - "item_id" - ], - "properties": { - "fields": { - "type": "array", - "description": "Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included.", - "items": { - "type": "string" - } - }, - "item_id": { - "type": "number", - "description": "The item's ID." - }, - "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." - }, - "owner_type": { - "type": "string", - "description": "Owner type", - "enum": [ - "user", - "org" - ] - }, - "project_number": { - "type": "number", - "description": "The project's number." - } - } - }, - "name": "get_project_item" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_release_by_tag.snap b/pkg/github/__toolsnaps__/get_release_by_tag.snap index 77f19488c8..6e6d30e980 100644 --- a/pkg/github/__toolsnaps__/get_release_by_tag.snap +++ b/pkg/github/__toolsnaps__/get_release_by_tag.snap @@ -5,26 +5,26 @@ }, "description": "Get a specific release by its tag name in a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "tag" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "tag": { - "type": "string", - "description": "Tag name (e.g., 'v1.0.0')" + "description": "Tag name (e.g., 'v1.0.0')", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "tag" + ], + "type": "object" }, "name": "get_release_by_tag" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_repository_tree.snap b/pkg/github/__toolsnaps__/get_repository_tree.snap index 8824628839..c810d1e209 100644 --- a/pkg/github/__toolsnaps__/get_repository_tree.snap +++ b/pkg/github/__toolsnaps__/get_repository_tree.snap @@ -5,34 +5,34 @@ }, "description": "Get the tree structure (files and directories) of a GitHub repository at a specific ref or SHA", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner (username or organization)" + "description": "Repository owner (username or organization)", + "type": "string" }, "path_filter": { - "type": "string", - "description": "Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory)" + "description": "Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory)", + "type": "string" }, "recursive": { - "type": "boolean", + "default": false, "description": "Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false", - "default": false + "type": "boolean" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "tree_sha": { - "type": "string", - "description": "The SHA1 value or ref (branch or tag) name of the tree. Defaults to the repository's default branch" + "description": "The SHA1 value or ref (branch or tag) name of the tree. Defaults to the repository's default branch", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "get_repository_tree" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap b/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap index 4d55011da3..2789cfbabc 100644 --- a/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap +++ b/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap @@ -5,26 +5,26 @@ }, "description": "Get details of a specific secret scanning alert in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "alertNumber" - ], "properties": { "alertNumber": { - "type": "number", - "description": "The number of the alert." + "description": "The number of the alert.", + "type": "number" }, "owner": { - "type": "string", - "description": "The owner of the repository." + "description": "The owner of the repository.", + "type": "string" }, "repo": { - "type": "string", - "description": "The name of the repository." + "description": "The name of the repository.", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "alertNumber" + ], + "type": "object" }, "name": "get_secret_scanning_alert" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_tag.snap b/pkg/github/__toolsnaps__/get_tag.snap index e33f5c2e4b..126e8a9996 100644 --- a/pkg/github/__toolsnaps__/get_tag.snap +++ b/pkg/github/__toolsnaps__/get_tag.snap @@ -5,26 +5,26 @@ }, "description": "Get details about a specific git tag in a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "tag" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "tag": { - "type": "string", - "description": "Tag name" + "description": "Tag name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "tag" + ], + "type": "object" }, "name": "get_tag" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_team_members.snap b/pkg/github/__toolsnaps__/get_team_members.snap index 5b7f090fe1..4cde7237c1 100644 --- a/pkg/github/__toolsnaps__/get_team_members.snap +++ b/pkg/github/__toolsnaps__/get_team_members.snap @@ -5,21 +5,21 @@ }, "description": "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials", "inputSchema": { - "type": "object", - "required": [ - "org", - "team_slug" - ], "properties": { "org": { - "type": "string", - "description": "Organization login (owner) that contains the team." + "description": "Organization login (owner) that contains the team.", + "type": "string" }, "team_slug": { - "type": "string", - "description": "Team slug" + "description": "Team slug", + "type": "string" } - } + }, + "required": [ + "org", + "team_slug" + ], + "type": "object" }, "name": "get_team_members" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_teams.snap b/pkg/github/__toolsnaps__/get_teams.snap index 595dd262df..946364bad8 100644 --- a/pkg/github/__toolsnaps__/get_teams.snap +++ b/pkg/github/__toolsnaps__/get_teams.snap @@ -5,13 +5,13 @@ }, "description": "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials", "inputSchema": { - "type": "object", "properties": { "user": { - "type": "string", - "description": "Username to get teams for. If not provided, uses the authenticated user." + "description": "Username to get teams for. If not provided, uses the authenticated user.", + "type": "string" } - } + }, + "type": "object" }, "name": "get_teams" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_workflow_run.snap b/pkg/github/__toolsnaps__/get_workflow_run.snap deleted file mode 100644 index 37921ffadf..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": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "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 77fb619b72..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": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "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 c9fe49f96f..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": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "name": "get_workflow_run_usage" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_read.snap b/pkg/github/__toolsnaps__/issue_read.snap index c6a9e7306f..21aa361f5e 100644 --- a/pkg/github/__toolsnaps__/issue_read.snap +++ b/pkg/github/__toolsnaps__/issue_read.snap @@ -5,48 +5,48 @@ }, "description": "Get information about a specific issue in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner", - "repo", - "issue_number" - ], "properties": { "issue_number": { - "type": "number", - "description": "The number of the issue" + "description": "The number of the issue", + "type": "number" }, "method": { - "type": "string", "description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get details of a specific issue.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues of the issue.\n4. get_labels - Get labels assigned to the issue.\n", "enum": [ "get", "get_comments", "get_sub_issues", "get_labels" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "The owner of the repository" + "description": "The owner of the repository", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "The name of the repository" + "description": "The name of the repository", + "type": "string" } - } + }, + "required": [ + "method", + "owner", + "repo", + "issue_number" + ], + "type": "object" }, "name": "issue_read" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index 8c6634a02a..a125864f04 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -1,88 +1,97 @@ { + "_meta": { + "ui": { + "resourceUri": "ui://github-mcp-server/issue-write", + "visibility": [ + "model", + "app" + ] + } + }, "annotations": { - "title": "Create or update issue." + "title": "Create or update issue" }, "description": "Create a new or update an existing issue in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner", - "repo" - ], "properties": { "assignees": { - "type": "array", "description": "Usernames to assign to this issue", "items": { "type": "string" - } + }, + "type": "array" }, "body": { - "type": "string", - "description": "Issue body content" + "description": "Issue body content", + "type": "string" }, "duplicate_of": { - "type": "number", - "description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'." + "description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", + "type": "number" }, "issue_number": { - "type": "number", - "description": "Issue number to update" + "description": "Issue number to update", + "type": "number" }, "labels": { - "type": "array", "description": "Labels to apply to this issue", "items": { "type": "string" - } + }, + "type": "array" }, "method": { - "type": "string", "description": "Write operation to perform on a single issue.\nOptions are:\n- 'create' - creates a new issue.\n- 'update' - updates an existing issue.\n", "enum": [ "create", "update" - ] + ], + "type": "string" }, "milestone": { - "type": "number", - "description": "Milestone number" + "description": "Milestone number", + "type": "number" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "state": { - "type": "string", "description": "New state", "enum": [ "open", "closed" - ] + ], + "type": "string" }, "state_reason": { - "type": "string", "description": "Reason for the state change. Ignored unless state is changed.", "enum": [ "completed", "not_planned", "duplicate" - ] + ], + "type": "string" }, "title": { - "type": "string", - "description": "Issue title" + "description": "Issue title", + "type": "string" }, "type": { - "type": "string", - "description": "Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter." + "description": "Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter.", + "type": "string" } - } + }, + "required": [ + "method", + "owner", + "repo" + ], + "type": "object" }, "name": "issue_write" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap b/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap new file mode 100644 index 0000000000..6fb00d2490 --- /dev/null +++ b/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap @@ -0,0 +1,133 @@ +{ + "_meta": { + "ui": { + "resourceUri": "ui://github-mcp-server/issue-write", + "visibility": [ + "model", + "app" + ] + } + }, + "annotations": { + "title": "Create or update issue" + }, + "description": "Create a new or update an existing issue in a GitHub repository.", + "inputSchema": { + "properties": { + "assignees": { + "description": "Usernames to assign to this issue", + "items": { + "type": "string" + }, + "type": "array" + }, + "body": { + "description": "Issue body content", + "type": "string" + }, + "duplicate_of": { + "description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", + "type": "number" + }, + "issue_fields": { + "description": "Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'.", + "items": { + "additionalProperties": false, + "properties": { + "delete": { + "description": "Set to true to clear this field's current value on the issue. Cannot be combined with 'value' or 'field_option_name'.", + "enum": [ + true + ], + "type": "boolean" + }, + "field_name": { + "description": "Issue field name (case-insensitive). Must match a field returned by list_issue_fields for this repository or its organization.", + "type": "string" + }, + "field_option_name": { + "description": "Option name for single-select fields. Validated against the field's options before the API call. Cannot be combined with 'value' or 'delete'.", + "type": "string" + }, + "value": { + "description": "Value to set. Use for text, number, and date fields (date as YYYY-MM-DD). For single-select fields, prefer 'field_option_name' so the option is validated before the API call. Cannot be combined with 'field_option_name' or 'delete'.", + "type": [ + "string", + "number", + "boolean" + ] + } + }, + "required": [ + "field_name" + ], + "type": "object" + }, + "type": "array" + }, + "issue_number": { + "description": "Issue number to update", + "type": "number" + }, + "labels": { + "description": "Labels to apply to this issue", + "items": { + "type": "string" + }, + "type": "array" + }, + "method": { + "description": "Write operation to perform on a single issue.\nOptions are:\n- 'create' - creates a new issue.\n- 'update' - updates an existing issue.\n", + "enum": [ + "create", + "update" + ], + "type": "string" + }, + "milestone": { + "description": "Milestone number", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "state": { + "description": "New state", + "enum": [ + "open", + "closed" + ], + "type": "string" + }, + "state_reason": { + "description": "Reason for the state change. Ignored unless state is changed.", + "enum": [ + "completed", + "not_planned", + "duplicate" + ], + "type": "string" + }, + "title": { + "description": "Issue title", + "type": "string" + }, + "type": { + "description": "Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter.", + "type": "string" + } + }, + "required": [ + "method", + "owner", + "repo" + ], + "type": "object" + }, + "name": "issue_write" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/label_write.snap b/pkg/github/__toolsnaps__/label_write.snap index 879817442a..de4b98bef7 100644 --- a/pkg/github/__toolsnaps__/label_write.snap +++ b/pkg/github/__toolsnaps__/label_write.snap @@ -1,51 +1,51 @@ { "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": { - "type": "object", - "required": [ - "method", - "owner", - "repo", - "name" - ], "properties": { "color": { - "type": "string", - "description": "Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'." + "description": "Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'.", + "type": "string" }, "description": { - "type": "string", - "description": "Label description text. Optional for 'create' and 'update'." + "description": "Label description text. Optional for 'create' and 'update'.", + "type": "string" }, "method": { - "type": "string", "description": "Operation to perform: 'create', 'update', or 'delete'", "enum": [ "create", "update", "delete" - ] + ], + "type": "string" }, "name": { - "type": "string", - "description": "Label name - required for all operations" + "description": "Label name - required for all operations", + "type": "string" }, "new_name": { - "type": "string", - "description": "New name for the label (used only with 'update' method to rename)" + "description": "New name for the label (used only with 'update' method to rename)", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner (username or organization name)" + "description": "Repository owner (username or organization name)", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "method", + "owner", + "repo", + "name" + ], + "type": "object" }, "name": "label_write" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_branches.snap b/pkg/github/__toolsnaps__/list_branches.snap index b589c9b7e1..883a6fffcb 100644 --- a/pkg/github/__toolsnaps__/list_branches.snap +++ b/pkg/github/__toolsnaps__/list_branches.snap @@ -5,32 +5,32 @@ }, "description": "List branches in a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_branches" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap index 6f2a4e3427..9eddf045d8 100644 --- a/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap +++ b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap @@ -5,26 +5,31 @@ }, "description": "List code scanning alerts in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "The owner of the repository." + "description": "The owner of the repository.", + "type": "string" + }, + "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": { - "type": "string", - "description": "The Git reference for the results you want to list." + "description": "The Git reference for the results you want to list.", + "type": "string" }, "repo": { - "type": "string", - "description": "The name of the repository." + "description": "The name of the repository.", + "type": "string" }, "severity": { - "type": "string", "description": "Filter code scanning alerts by severity", "enum": [ "critical", @@ -34,24 +39,30 @@ "warning", "note", "error" - ] + ], + "type": "string" }, "state": { - "type": "string", - "description": "Filter code scanning alerts by state. Defaults to open", "default": "open", + "description": "Filter code scanning alerts by state. Defaults to open", "enum": [ "open", "closed", "dismissed", "fixed" - ] + ], + "type": "string" }, "tool_name": { - "type": "string", - "description": "The name of the tool used for code scanning." + "description": "The name of the tool used for code scanning.", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_code_scanning_alerts" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_commits.snap b/pkg/github/__toolsnaps__/list_commits.snap index bd67602ed7..1a773f217e 100644 --- a/pkg/github/__toolsnaps__/list_commits.snap +++ b/pkg/github/__toolsnaps__/list_commits.snap @@ -5,40 +5,52 @@ }, "description": "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "author": { - "type": "string", - "description": "Author username or email address to filter commits by" + "description": "Author username or email address to filter commits by", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" + }, + "path": { + "description": "Only commits containing this file path will be returned", + "type": "string" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "sha": { - "type": "string", - "description": "Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA." + "description": "Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA.", + "type": "string" + }, + "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": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_commits" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap index d96d3972c5..55d5437796 100644 --- a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap +++ b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap @@ -5,42 +5,53 @@ }, "description": "List dependabot alerts in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "The owner of the repository." + "description": "The owner of the repository.", + "type": "string" + }, + "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": { - "type": "string", - "description": "The name of the repository." + "description": "The name of the repository.", + "type": "string" }, "severity": { - "type": "string", "description": "Filter dependabot alerts by severity", "enum": [ "low", "medium", "high", "critical" - ] + ], + "type": "string" }, "state": { - "type": "string", - "description": "Filter dependabot alerts by state. Defaults to open", "default": "open", + "description": "Filter dependabot alerts by state. Defaults to open", "enum": [ "open", "fixed", "dismissed", "auto_dismissed" - ] + ], + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_dependabot_alerts" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_discussion_categories.snap b/pkg/github/__toolsnaps__/list_discussion_categories.snap index 888ebbdcad..c46b75f84c 100644 --- a/pkg/github/__toolsnaps__/list_discussion_categories.snap +++ b/pkg/github/__toolsnaps__/list_discussion_categories.snap @@ -5,20 +5,20 @@ }, "description": "List discussion categories with their id and name, for a repository or organisation.", "inputSchema": { - "type": "object", - "required": [ - "owner" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name. If not provided, discussion categories will be queried at the organisation level." + "description": "Repository name. If not provided, discussion categories will be queried at the organisation level.", + "type": "string" } - } + }, + "required": [ + "owner" + ], + "type": "object" }, "name": "list_discussion_categories" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_discussions.snap b/pkg/github/__toolsnaps__/list_discussions.snap index 95a8bebf56..42be769335 100644 --- a/pkg/github/__toolsnaps__/list_discussions.snap +++ b/pkg/github/__toolsnaps__/list_discussions.snap @@ -5,50 +5,50 @@ }, "description": "List discussions for a repository or organisation.", "inputSchema": { - "type": "object", - "required": [ - "owner" - ], "properties": { "after": { - "type": "string", - "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs." + "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + "type": "string" }, "category": { - "type": "string", - "description": "Optional filter by discussion category ID. If provided, only discussions with this category are listed." + "description": "Optional filter by discussion category ID. If provided, only discussions with this category are listed.", + "type": "string" }, "direction": { - "type": "string", "description": "Order direction.", "enum": [ "ASC", "DESC" - ] + ], + "type": "string" }, "orderBy": { - "type": "string", "description": "Order discussions by field. If provided, the 'direction' also needs to be provided.", "enum": [ "CREATED_AT", "UPDATED_AT" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name. If not provided, discussions will be queried at the organisation level." + "description": "Repository name. If not provided, discussions will be queried at the organisation level.", + "type": "string" } - } + }, + "required": [ + "owner" + ], + "type": "object" }, "name": "list_discussions" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_gists.snap b/pkg/github/__toolsnaps__/list_gists.snap index 834b452050..3974173033 100644 --- a/pkg/github/__toolsnaps__/list_gists.snap +++ b/pkg/github/__toolsnaps__/list_gists.snap @@ -5,28 +5,28 @@ }, "description": "List gists for a user", "inputSchema": { - "type": "object", "properties": { "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "since": { - "type": "string", - "description": "Only gists updated after this time (ISO 8601 timestamp)" + "description": "Only gists updated after this time (ISO 8601 timestamp)", + "type": "string" }, "username": { - "type": "string", - "description": "GitHub username (omit for authenticated user's gists)" + "description": "GitHub username (omit for authenticated user's gists)", + "type": "string" } - } + }, + "type": "object" }, "name": "list_gists" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_global_security_advisories.snap b/pkg/github/__toolsnaps__/list_global_security_advisories.snap index fd9fa78c58..f714f47829 100644 --- a/pkg/github/__toolsnaps__/list_global_security_advisories.snap +++ b/pkg/github/__toolsnaps__/list_global_security_advisories.snap @@ -5,25 +5,23 @@ }, "description": "List global security advisories from GitHub.", "inputSchema": { - "type": "object", "properties": { "affects": { - "type": "string", - "description": "Filter advisories by affected package or version (e.g. \"package1,package2@1.0.0\")." + "description": "Filter advisories by affected package or version (e.g. \"package1,package2@1.0.0\").", + "type": "string" }, "cveId": { - "type": "string", - "description": "Filter by CVE ID." + "description": "Filter by CVE ID.", + "type": "string" }, "cwes": { - "type": "array", "description": "Filter by Common Weakness Enumeration IDs (e.g. [\"79\", \"284\", \"22\"]).", "items": { "type": "string" - } + }, + "type": "array" }, "ecosystem": { - "type": "string", "description": "Filter by package ecosystem.", "enum": [ "actions", @@ -38,26 +36,26 @@ "pub", "rubygems", "rust" - ] + ], + "type": "string" }, "ghsaId": { - "type": "string", - "description": "Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)." + "description": "Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", + "type": "string" }, "isWithdrawn": { - "type": "boolean", - "description": "Whether to only return withdrawn advisories." + "description": "Whether to only return withdrawn advisories.", + "type": "boolean" }, "modified": { - "type": "string", - "description": "Filter by publish or update date or date range (ISO 8601 date or range)." + "description": "Filter by publish or update date or date range (ISO 8601 date or range).", + "type": "string" }, "published": { - "type": "string", - "description": "Filter by publish date or date range (ISO 8601 date or range)." + "description": "Filter by publish date or date range (ISO 8601 date or range).", + "type": "string" }, "severity": { - "type": "string", "description": "Filter by severity.", "enum": [ "unknown", @@ -65,23 +63,25 @@ "medium", "high", "critical" - ] + ], + "type": "string" }, "type": { - "type": "string", - "description": "Advisory type.", "default": "reviewed", + "description": "Advisory type.", "enum": [ "reviewed", "malware", "unreviewed" - ] + ], + "type": "string" }, "updated": { - "type": "string", - "description": "Filter by update date or date range (ISO 8601 date or range)." + "description": "Filter by update date or date range (ISO 8601 date or range).", + "type": "string" } - } + }, + "type": "object" }, "name": "list_global_security_advisories" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issue_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 b17dcc54f9..f1f1377a81 100644 --- a/pkg/github/__toolsnaps__/list_issue_types.snap +++ b/pkg/github/__toolsnaps__/list_issue_types.snap @@ -5,16 +5,16 @@ }, "description": "List supported issue types for repository owner (organization).", "inputSchema": { - "type": "object", - "required": [ - "owner" - ], "properties": { "owner": { - "type": "string", - "description": "The organization owner of the repository" + "description": "The organization owner of the repository", + "type": "string" } - } + }, + "required": [ + "owner" + ], + "type": "object" }, "name": "list_issue_types" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap index 9d6b555865..a4be59bb0c 100644 --- a/pkg/github/__toolsnaps__/list_issues.snap +++ b/pkg/github/__toolsnaps__/list_issues.snap @@ -5,67 +5,67 @@ }, "description": "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "after": { - "type": "string", - "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs." + "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + "type": "string" }, "direction": { - "type": "string", "description": "Order direction. If provided, the 'orderBy' also needs to be provided.", "enum": [ "ASC", "DESC" - ] + ], + "type": "string" }, "labels": { - "type": "array", "description": "Filter by labels", "items": { "type": "string" - } + }, + "type": "array" }, "orderBy": { - "type": "string", "description": "Order issues by field. If provided, the 'direction' also needs to be provided.", "enum": [ "CREATED_AT", "UPDATED_AT", "COMMENTS" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "since": { - "type": "string", - "description": "Filter by date (ISO 8601 timestamp)" + "description": "Filter by date (ISO 8601 timestamp)", + "type": "string" }, "state": { - "type": "string", "description": "Filter by state, by default both open and closed issues are returned when not provided", "enum": [ "OPEN", "CLOSED" - ] + ], + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_issues" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap b/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap new file mode 100644 index 0000000000..b1d1c7a21d --- /dev/null +++ b/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap @@ -0,0 +1,92 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List issues" + }, + "description": "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.", + "inputSchema": { + "properties": { + "after": { + "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + "type": "string" + }, + "direction": { + "description": "Order direction. If provided, the 'orderBy' also needs to be provided.", + "enum": [ + "ASC", + "DESC" + ], + "type": "string" + }, + "field_filters": { + "description": "Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date).", + "items": { + "properties": { + "field_name": { + "description": "Name of the custom field (e.g. \"Priority\"). Case-insensitive.", + "type": "string" + }, + "value": { + "description": "Value to filter on. For single-select fields, the option name (e.g. \"P1\"). For dates, YYYY-MM-DD. For numbers, the numeric value as a string. For text, the text value.", + "type": "string" + } + }, + "required": [ + "field_name", + "value" + ], + "type": "object" + }, + "type": "array" + }, + "labels": { + "description": "Filter by labels", + "items": { + "type": "string" + }, + "type": "array" + }, + "orderBy": { + "description": "Order issues by field. If provided, the 'direction' also needs to be provided.", + "enum": [ + "CREATED_AT", + "UPDATED_AT", + "COMMENTS" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "since": { + "description": "Filter by date (ISO 8601 timestamp)", + "type": "string" + }, + "state": { + "description": "Filter by state, by default both open and closed issues are returned when not provided", + "enum": [ + "OPEN", + "CLOSED" + ], + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "list_issues" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_label.snap b/pkg/github/__toolsnaps__/list_label.snap index 0b4f3b20c7..9bf8a9f3e0 100644 --- a/pkg/github/__toolsnaps__/list_label.snap +++ b/pkg/github/__toolsnaps__/list_label.snap @@ -1,25 +1,25 @@ { "annotations": { "readOnlyHint": true, - "title": "List labels from a repository." + "title": "List labels from a repository" }, "description": "List labels from a repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner (username or organization name) - required for all operations" + "description": "Repository owner (username or organization name) - required for all operations", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name - required for all operations" + "description": "Repository name - required for all operations", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_label" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_notifications.snap b/pkg/github/__toolsnaps__/list_notifications.snap index ae43e0f257..bf25c4fe07 100644 --- a/pkg/github/__toolsnaps__/list_notifications.snap +++ b/pkg/github/__toolsnaps__/list_notifications.snap @@ -5,45 +5,45 @@ }, "description": "Lists all GitHub notifications for the authenticated user, including unread notifications, mentions, review requests, assignments, and updates on issues or pull requests. Use this tool whenever the user asks what to work on next, requests a summary of their GitHub activity, wants to see pending reviews, or needs to check for new updates or tasks. This tool is the primary way to discover actionable items, reminders, and outstanding work on GitHub. Always call this tool when asked what to work on next, what is pending, or what needs attention in GitHub.", "inputSchema": { - "type": "object", "properties": { "before": { - "type": "string", - "description": "Only show notifications updated before the given time (ISO 8601 format)" + "description": "Only show notifications updated before the given time (ISO 8601 format)", + "type": "string" }, "filter": { - "type": "string", "description": "Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created.", "enum": [ "default", "include_read_notifications", "only_participating" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Optional repository owner. If provided with repo, only notifications for this repository are listed." + "description": "Optional repository owner. If provided with repo, only notifications for this repository are listed.", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Optional repository name. If provided with owner, only notifications for this repository are listed." + "description": "Optional repository name. If provided with owner, only notifications for this repository are listed.", + "type": "string" }, "since": { - "type": "string", - "description": "Only show notifications updated after the given time (ISO 8601 format)" + "description": "Only show notifications updated after the given time (ISO 8601 format)", + "type": "string" } - } + }, + "type": "object" }, "name": "list_notifications" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap b/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap index 5f8823659b..563da98c3c 100644 --- a/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap +++ b/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap @@ -5,43 +5,43 @@ }, "description": "List repository security advisories for a GitHub organization.", "inputSchema": { - "type": "object", - "required": [ - "org" - ], "properties": { "direction": { - "type": "string", "description": "Sort direction.", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "org": { - "type": "string", - "description": "The organization login." + "description": "The organization login.", + "type": "string" }, "sort": { - "type": "string", "description": "Sort field.", "enum": [ "created", "updated", "published" - ] + ], + "type": "string" }, "state": { - "type": "string", "description": "Filter by advisory state.", "enum": [ "triage", "draft", "published", "closed" - ] + ], + "type": "string" } - } + }, + "required": [ + "org" + ], + "type": "object" }, "name": "list_org_repository_security_advisories" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_project_fields.snap b/pkg/github/__toolsnaps__/list_project_fields.snap deleted file mode 100644 index 6bef185079..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": { - "type": "object", - "required": [ - "owner_type", - "owner", - "project_number" - ], - "properties": { - "after": { - "type": "string", - "description": "Forward pagination cursor from previous pageInfo.nextCursor." - }, - "before": { - "type": "string", - "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare)." - }, - "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." - }, - "owner_type": { - "type": "string", - "description": "Owner type", - "enum": [ - "user", - "org" - ] - }, - "per_page": { - "type": "number", - "description": "Results per page (max 50)" - }, - "project_number": { - "type": "number", - "description": "The project's number." - } - } - }, - "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 bceb5d9eb0..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": { - "type": "object", - "required": [ - "owner_type", - "owner", - "project_number" - ], - "properties": { - "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": { - "type": "string" - } - }, - "owner": { - "type": "string", - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." - }, - "owner_type": { - "type": "string", - "description": "Owner type", - "enum": [ - "user", - "org" - ] - }, - "per_page": { - "type": "number", - "description": "Results per page (max 50)" - }, - "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." - } - } - }, - "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 f48e262173..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": { - "type": "object", - "required": [ - "owner_type", - "owner" - ], - "properties": { - "after": { - "type": "string", - "description": "Forward pagination cursor from previous pageInfo.nextCursor." - }, - "before": { - "type": "string", - "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare)." - }, - "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." - }, - "owner_type": { - "type": "string", - "description": "Owner type", - "enum": [ - "user", - "org" - ] - }, - "per_page": { - "type": "number", - "description": "Results per page (max 50)" - }, - "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\"." - } - } - }, - "name": "list_projects" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_pull_requests.snap b/pkg/github/__toolsnaps__/list_pull_requests.snap index ae90c3fe05..25f1268c64 100644 --- a/pkg/github/__toolsnaps__/list_pull_requests.snap +++ b/pkg/github/__toolsnaps__/list_pull_requests.snap @@ -5,67 +5,67 @@ }, "description": "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "base": { - "type": "string", - "description": "Filter by base branch" + "description": "Filter by base branch", + "type": "string" }, "direction": { - "type": "string", "description": "Sort direction", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "head": { - "type": "string", - "description": "Filter by head user/org and branch" + "description": "Filter by head user/org and branch", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "sort": { - "type": "string", "description": "Sort by", "enum": [ "created", "updated", "popularity", "long-running" - ] + ], + "type": "string" }, "state": { - "type": "string", "description": "Filter by state", "enum": [ "open", "closed", "all" - ] + ], + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_pull_requests" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_releases.snap b/pkg/github/__toolsnaps__/list_releases.snap index 98d4ce66fc..57502c3c86 100644 --- a/pkg/github/__toolsnaps__/list_releases.snap +++ b/pkg/github/__toolsnaps__/list_releases.snap @@ -5,32 +5,32 @@ }, "description": "List releases in a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_releases" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_repository_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_repository_security_advisories.snap b/pkg/github/__toolsnaps__/list_repository_security_advisories.snap index 465fd881e6..c86508f927 100644 --- a/pkg/github/__toolsnaps__/list_repository_security_advisories.snap +++ b/pkg/github/__toolsnaps__/list_repository_security_advisories.snap @@ -5,48 +5,48 @@ }, "description": "List repository security advisories for a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "direction": { - "type": "string", "description": "Sort direction.", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "The owner of the repository." + "description": "The owner of the repository.", + "type": "string" }, "repo": { - "type": "string", - "description": "The name of the repository." + "description": "The name of the repository.", + "type": "string" }, "sort": { - "type": "string", "description": "Sort field.", "enum": [ "created", "updated", "published" - ] + ], + "type": "string" }, "state": { - "type": "string", "description": "Filter by advisory state.", "enum": [ "triage", "draft", "published", "closed" - ] + ], + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_repository_security_advisories" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap b/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap index e7896c55f2..5c6a21a0ab 100644 --- a/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap +++ b/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap @@ -5,22 +5,27 @@ }, "description": "List secret scanning alerts in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "The owner of the repository." + "description": "The owner of the repository.", + "type": "string" + }, + "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": { - "type": "string", - "description": "The name of the repository." + "description": "The name of the repository.", + "type": "string" }, "resolution": { - "type": "string", "description": "Filter by resolution", "enum": [ "false_positive", @@ -29,21 +34,27 @@ "pattern_edited", "pattern_deleted", "used_in_tests" - ] + ], + "type": "string" }, "secret_type": { - "type": "string", - "description": "A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter." + "description": "A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter.", + "type": "string" }, "state": { - "type": "string", "description": "Filter by state", "enum": [ "open", "resolved" - ] + ], + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_secret_scanning_alerts" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_starred_repositories.snap b/pkg/github/__toolsnaps__/list_starred_repositories.snap index a383b39d14..e631719fde 100644 --- a/pkg/github/__toolsnaps__/list_starred_repositories.snap +++ b/pkg/github/__toolsnaps__/list_starred_repositories.snap @@ -5,40 +5,40 @@ }, "description": "List starred repositories", "inputSchema": { - "type": "object", "properties": { "direction": { - "type": "string", "description": "The direction to sort the results by.", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "sort": { - "type": "string", "description": "How to sort the results. Can be either 'created' (when the repository was starred) or 'updated' (when the repository was last pushed to).", "enum": [ "created", "updated" - ] + ], + "type": "string" }, "username": { - "type": "string", - "description": "Username to list starred repositories for. Defaults to the authenticated user." + "description": "Username to list starred repositories for. Defaults to the authenticated user.", + "type": "string" } - } + }, + "type": "object" }, "name": "list_starred_repositories" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_tags.snap b/pkg/github/__toolsnaps__/list_tags.snap index 5b667d19c6..1e66d2c1f6 100644 --- a/pkg/github/__toolsnaps__/list_tags.snap +++ b/pkg/github/__toolsnaps__/list_tags.snap @@ -5,32 +5,32 @@ }, "description": "List git tags in a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "list_tags" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_workflow_jobs.snap b/pkg/github/__toolsnaps__/list_workflow_jobs.snap deleted file mode 100644 index 59ff75afc2..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": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "filter": { - "type": "string", - "description": "Filters jobs by their completed_at timestamp", - "enum": [ - "latest", - "all" - ] - }, - "owner": { - "type": "string", - "description": "Repository owner" - }, - "page": { - "type": "number", - "description": "Page number for pagination (min 1)", - "minimum": 1 - }, - "perPage": { - "type": "number", - "description": "Results per page for pagination (min 1, max 100)", - "minimum": 1, - "maximum": 100 - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "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 6d6332d740..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": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "page": { - "type": "number", - "description": "Page number for pagination (min 1)", - "minimum": 1 - }, - "perPage": { - "type": "number", - "description": "Results per page for pagination (min 1, max 100)", - "minimum": 1, - "maximum": 100 - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "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 e5353f4904..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": { - "type": "object", - "required": [ - "owner", - "repo", - "workflow_id" - ], - "properties": { - "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": [ - "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" - ] - }, - "owner": { - "type": "string", - "description": "Repository owner" - }, - "page": { - "type": "number", - "description": "Page number for pagination (min 1)", - "minimum": 1 - }, - "perPage": { - "type": "number", - "description": "Results per page for pagination (min 1, max 100)", - "minimum": 1, - "maximum": 100 - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "status": { - "type": "string", - "description": "Returns workflow runs with the check run status", - "enum": [ - "queued", - "in_progress", - "completed", - "requested", - "waiting" - ] - }, - "workflow_id": { - "type": "string", - "description": "The workflow ID or workflow file name" - } - } - }, - "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 f3f52f042f..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": { - "type": "object", - "required": [ - "owner", - "repo" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "page": { - "type": "number", - "description": "Page number for pagination (min 1)", - "minimum": 1 - }, - "perPage": { - "type": "number", - "description": "Results per page for pagination (min 1, max 100)", - "minimum": 1, - "maximum": 100 - }, - "repo": { - "type": "string", - "description": "Repository name" - } - } - }, - "name": "list_workflows" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/manage_notification_subscription.snap b/pkg/github/__toolsnaps__/manage_notification_subscription.snap index 4f0d466a08..e04acd11e8 100644 --- a/pkg/github/__toolsnaps__/manage_notification_subscription.snap +++ b/pkg/github/__toolsnaps__/manage_notification_subscription.snap @@ -4,26 +4,26 @@ }, "description": "Manage a notification subscription: ignore, watch, or delete a notification thread subscription.", "inputSchema": { - "type": "object", - "required": [ - "notificationID", - "action" - ], "properties": { "action": { - "type": "string", "description": "Action to perform: ignore, watch, or delete the notification subscription.", "enum": [ "ignore", "watch", "delete" - ] + ], + "type": "string" }, "notificationID": { - "type": "string", - "description": "The ID of the notification thread." + "description": "The ID of the notification thread.", + "type": "string" } - } + }, + "required": [ + "notificationID", + "action" + ], + "type": "object" }, "name": "manage_notification_subscription" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap b/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap index 82ee40a895..0a4567b71c 100644 --- a/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap +++ b/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap @@ -4,31 +4,31 @@ }, "description": "Manage a repository notification subscription: ignore, watch, or delete repository notifications subscription for the provided repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "action" - ], "properties": { "action": { - "type": "string", "description": "Action to perform: ignore, watch, or delete the repository notification subscription.", "enum": [ "ignore", "watch", "delete" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "The account owner of the repository." + "description": "The account owner of the repository.", + "type": "string" }, "repo": { - "type": "string", - "description": "The name of the repository." + "description": "The name of the repository.", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "action" + ], + "type": "object" }, "name": "manage_repository_notification_subscription" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/mark_all_notifications_read.snap b/pkg/github/__toolsnaps__/mark_all_notifications_read.snap index 2d45ed78d3..1f5a32284e 100644 --- a/pkg/github/__toolsnaps__/mark_all_notifications_read.snap +++ b/pkg/github/__toolsnaps__/mark_all_notifications_read.snap @@ -4,21 +4,21 @@ }, "description": "Mark all notifications as read", "inputSchema": { - "type": "object", "properties": { "lastReadAt": { - "type": "string", - "description": "Describes the last point that notifications were checked (optional). Default: Now" + "description": "Describes the last point that notifications were checked (optional). Default: Now", + "type": "string" }, "owner": { - "type": "string", - "description": "Optional repository owner. If provided with repo, only notifications for this repository are marked as read." + "description": "Optional repository owner. If provided with repo, only notifications for this repository are marked as read.", + "type": "string" }, "repo": { - "type": "string", - "description": "Optional repository name. If provided with owner, only notifications for this repository are marked as read." + "description": "Optional repository name. If provided with owner, only notifications for this repository are marked as read.", + "type": "string" } - } + }, + "type": "object" }, "name": "mark_all_notifications_read" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/merge_pull_request.snap b/pkg/github/__toolsnaps__/merge_pull_request.snap index 179805b3a5..d0cdb2b1ad 100644 --- a/pkg/github/__toolsnaps__/merge_pull_request.snap +++ b/pkg/github/__toolsnaps__/merge_pull_request.snap @@ -3,56 +3,56 @@ "title": "Merge pull request" }, "description": "Merge a pull request in a GitHub repository.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACeElEQVRIibWVTUhUYRSGn/e74+iiQih1F9Vcmj9sptylUVBYkO4jcNeuJBdFKxe1CYQokGrRKjCEdtmqwEVmtqomQWeiUdc2EBUtUufe0yLHn1KLGXtX5zvn4zz3vd8f/Gfp90Qs0drmpA6MT1EveDo1NfV92wB+KnMdo39Nfs4L7eSHD5Nz1QJcJYglWtsw+iUehAuRRjO1g+0KHLerbb4OIHnHAC1FdW129s3XmUJuwnBDoOPbA7BwHsD7QWq1HKYN5msBRCpB1AueLoSROSkciSUyj5ClhE6BLtYC8CpBqVRabNrdMmIiJdQjuUbQ1WI+d78WwIbykxnzU9np7ejlNq2YxQ4ebNtTKyCyWcEgYl55EDj/a7ihFEtkLkr0As2YxjwL+9aem00dCEYNzvnJzLDvH27aaM5y80HEnKGHKGwPnEbT6fSOvzpAmrDQnkncpC7siiUzz2QqIPu25iOuGBorTufO/AJmH0v2ajHwuoHhrQHATOH9rQPJ7IjDLgs6kZ0F6it1AzArVcZLdUE+WnYgmv/uYFmz+dxH4NJGNT+RfYLCE7F4tn0pGkxHy94AmBm8/GfAVvIs7AukUTkbj5YdYIbZ9WJh8m1lzrrbNB4/tD+QuyPsdCibF26gmM/dY/NdRDqd3rEYeN04mswYL+ZXm68DxOPxnWXXMClsp+GGhCWBTtClYj53t1qXK78oVH2XYB/mHZ0pvHsN4Cczzw3rBaoGrJ6D5ZUvN1i+kjI0LWiptjmscbC88hZZCAf2trZeq1v0UsJ6wF7UAlhxUMxPvkW6AboQLbvPcjaO+BIx11cL4I9H308eOiLRQUhpOx79/66fNKzrOCYNDm0AAAAASUVORK5CYII=", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABjElEQVRIibWVPS/DURTGnysSC0HiZdWVrZ28JDaLT8BHaBsMdjqZJDXiAzC2LF5mX6GtATGiIsGARH+Gnj9X8a/kf3uWe3Py3Oc559xz75E6bK7VAWQkzUi6lXTonHsOpgYUgAZfdgmkQpFnjHwb6AemgDpQCiWwYlEPeL4i8JCEt8vb39g67vkmPH8yA3qt5nVgCzi1jLJBBEwkBZSAdxPKAj86LYQQQCU4cYvAKzDUSYF3YC+uRIAD8sA58ACU//VuTODE1n1g+A9c3jBH1tJ1a5TeCPNrdACSCpKeJG1IepN0LKkm6dGDrkqqOOdm7dyUpDNJi865PUnqjsvEObcJHEhaljQnaV5STwvszttXbR2J441KtB4LauLKVpZpYBDYte8mHUogZTWPrAGstTtQBl6AayDX7qHZD7AALMVGDvQBV5ZyETi2qHLtMvmXWRQAk57vBKgl4fV/0+jmq56vImk0icCnAWm7pB3riGngnlADx0TW+T4yL4CxJJy/Df20mkP/TqGHfifsA7INs3X5i3+yAAAAAElFTkSuQmCC", + "theme": "dark" + } + ], "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "pullNumber" - ], "properties": { "commit_message": { - "type": "string", - "description": "Extra detail for merge commit" + "description": "Extra detail for merge commit", + "type": "string" }, "commit_title": { - "type": "string", - "description": "Title for merge commit" + "description": "Title for merge commit", + "type": "string" }, "merge_method": { - "type": "string", "description": "Merge method", "enum": [ "merge", "squash", "rebase" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "pullNumber": { - "type": "number", - "description": "Pull request number" + "description": "Pull request number", + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } - }, - "name": "merge_pull_request", - "icons": [ - { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACeElEQVRIibWVTUhUYRSGn/e74+iiQih1F9Vcmj9sptylUVBYkO4jcNeuJBdFKxe1CYQokGrRKjCEdtmqwEVmtqomQWeiUdc2EBUtUufe0yLHn1KLGXtX5zvn4zz3vd8f/Gfp90Qs0drmpA6MT1EveDo1NfV92wB+KnMdo39Nfs4L7eSHD5Nz1QJcJYglWtsw+iUehAuRRjO1g+0KHLerbb4OIHnHAC1FdW129s3XmUJuwnBDoOPbA7BwHsD7QWq1HKYN5msBRCpB1AueLoSROSkciSUyj5ClhE6BLtYC8CpBqVRabNrdMmIiJdQjuUbQ1WI+d78WwIbykxnzU9np7ejlNq2YxQ4ebNtTKyCyWcEgYl55EDj/a7ihFEtkLkr0As2YxjwL+9aem00dCEYNzvnJzLDvH27aaM5y80HEnKGHKGwPnEbT6fSOvzpAmrDQnkncpC7siiUzz2QqIPu25iOuGBorTufO/AJmH0v2ajHwuoHhrQHATOH9rQPJ7IjDLgs6kZ0F6it1AzArVcZLdUE+WnYgmv/uYFmz+dxH4NJGNT+RfYLCE7F4tn0pGkxHy94AmBm8/GfAVvIs7AukUTkbj5YdYIbZ9WJh8m1lzrrbNB4/tD+QuyPsdCibF26gmM/dY/NdRDqd3rEYeN04mswYL+ZXm68DxOPxnWXXMClsp+GGhCWBTtClYj53t1qXK78oVH2XYB/mHZ0pvHsN4Cczzw3rBaoGrJ6D5ZUvN1i+kjI0LWiptjmscbC88hZZCAf2trZeq1v0UsJ6wF7UAlhxUMxPvkW6AboQLbvPcjaO+BIx11cL4I9H308eOiLRQUhpOx79/66fNKzrOCYNDm0AAAAASUVORK5CYII=", - "mimeType": "image/png", - "theme": "light" }, - { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABjElEQVRIibWVPS/DURTGnysSC0HiZdWVrZ28JDaLT8BHaBsMdjqZJDXiAzC2LF5mX6GtATGiIsGARH+Gnj9X8a/kf3uWe3Py3Oc559xz75E6bK7VAWQkzUi6lXTonHsOpgYUgAZfdgmkQpFnjHwb6AemgDpQCiWwYlEPeL4i8JCEt8vb39g67vkmPH8yA3qt5nVgCzi1jLJBBEwkBZSAdxPKAj86LYQQQCU4cYvAKzDUSYF3YC+uRIAD8sA58ACU//VuTODE1n1g+A9c3jBH1tJ1a5TeCPNrdACSCpKeJG1IepN0LKkm6dGDrkqqOOdm7dyUpDNJi865PUnqjsvEObcJHEhaljQnaV5STwvszttXbR2J441KtB4LauLKVpZpYBDYte8mHUogZTWPrAGstTtQBl6AayDX7qHZD7AALMVGDvQBV5ZyETi2qHLtMvmXWRQAk57vBKgl4fV/0+jmq56vImk0icCnAWm7pB3riGngnlADx0TW+T4yL4CxJJy/Df20mkP/TqGHfifsA7INs3X5i3+yAAAAAElFTkSuQmCC", - "mimeType": "image/png", - "theme": "dark" - } - ] + "required": [ + "owner", + "repo", + "pullNumber" + ], + "type": "object" + }, + "name": "merge_pull_request" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap index 9758de0f2d..864f61d83f 100644 --- a/pkg/github/__toolsnaps__/projects_get.snap +++ b/pkg/github/__toolsnaps__/projects_get.snap @@ -5,55 +5,57 @@ }, "description": "Get details about specific GitHub Projects resources.\nUse this tool to get details about individual projects, project fields, and project items by their unique IDs.\n", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner_type", - "owner", - "project_number" - ], "properties": { "field_id": { - "type": "number", - "description": "The field's ID. Required for 'get_project_field' method." + "description": "The field's ID. Required for 'get_project_field' method.", + "type": "number" }, "fields": { - "type": "array", "description": "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included. Only used for 'get_project_item' method.", "items": { "type": "string" - } + }, + "type": "array" }, "item_id": { - "type": "number", - "description": "The item's ID. Required for 'get_project_item' method." + "description": "The item's ID. Required for 'get_project_item' method.", + "type": "number" }, "method": { - "type": "string", "description": "The method to execute", "enum": [ "get_project", "get_project_field", - "get_project_item" - ] + "get_project_item", + "get_project_status_update" + ], + "type": "string" }, "owner": { - "type": "string", - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." + "description": "The owner (user or organization login). The name is not case sensitive.", + "type": "string" }, "owner_type": { - "type": "string", - "description": "Owner type", + "description": "Owner type (user or org). If not provided, will be automatically detected.", "enum": [ "user", "org" - ] + ], + "type": "string" }, "project_number": { - "type": "number", - "description": "The project's 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" + ], + "type": "object" }, "name": "projects_get" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_list.snap b/pkg/github/__toolsnaps__/projects_list.snap index 7cc2e2df7b..c2bb0d3f49 100644 --- a/pkg/github/__toolsnaps__/projects_list.snap +++ b/pkg/github/__toolsnaps__/projects_list.snap @@ -5,62 +5,62 @@ }, "description": "Tools for listing GitHub Projects resources.\nUse this tool to list projects for a user or organization, or list project fields and items for a specific project.\n", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner_type", - "owner" - ], "properties": { "after": { - "type": "string", - "description": "Forward pagination cursor from previous pageInfo.nextCursor." + "description": "Forward pagination cursor from previous pageInfo.nextCursor.", + "type": "string" }, "before": { - "type": "string", - "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare)." + "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).", + "type": "string" }, "fields": { - "type": "array", "description": "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method.", "items": { "type": "string" - } + }, + "type": "array" }, "method": { - "type": "string", "description": "The action to perform", "enum": [ "list_projects", "list_project_fields", - "list_project_items" - ] + "list_project_items", + "list_project_status_updates" + ], + "type": "string" }, "owner": { - "type": "string", - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." + "description": "The owner (user or organization login). The name is not case sensitive.", + "type": "string" }, "owner_type": { - "type": "string", - "description": "Owner type", + "description": "Owner type (user or org). If not provided, will automatically try both.", "enum": [ "user", "org" - ] + ], + "type": "string" }, "per_page": { - "type": "number", - "description": "Results per page (max 50)" + "description": "Results per page (max 50)", + "type": "number" }, "project_number": { - "type": "number", - "description": "The project's number. 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": { - "type": "string", - "description": "Filter/query string. For list_projects: filter by title text and state (e.g. \"roadmap is:open\"). For list_project_items: advanced filtering using GitHub's project filtering syntax." + "description": "Filter/query string. For list_projects: filter by title text and state (e.g. \"roadmap is:open\"). For list_project_items: advanced filtering using GitHub's project filtering syntax.", + "type": "string" } - } + }, + "required": [ + "method", + "owner" + ], + "type": "object" }, "name": "projects_list" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index 2224590c53..6c9d349f63 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -1,60 +1,139 @@ { "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": { - "type": "object", - "required": [ - "method", - "owner_type", - "owner", - "project_number" - ], "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" + }, "item_id": { - "type": "number", - "description": "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. For add_project_item, this is the numeric ID of the issue or pull request to add." + "description": "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods.", + "type": "number" + }, + "item_owner": { + "description": "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method.", + "type": "string" + }, + "item_repo": { + "description": "The name of the repository containing the issue or pull request. Required for 'add_project_item' method.", + "type": "string" }, "item_type": { - "type": "string", "description": "The item's type, either issue or pull_request. Required for 'add_project_item' method.", "enum": [ "issue", "pull_request" - ] + ], + "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": { - "type": "string", "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" }, "owner": { - "type": "string", - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." + "description": "The project owner (user or organization login). The name is not case sensitive.", + "type": "string" }, "owner_type": { - "type": "string", - "description": "Owner type", + "description": "Owner type (user or org). Required for 'create_project' method. If not provided for other methods, will be automatically detected.", "enum": [ "user", "org" - ] + ], + "type": "string" }, "project_number": { - "type": "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": { - "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." + "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.", + "type": "object" } - } + }, + "required": [ + "method", + "owner" + ], + "type": "object" }, "name": "projects_write" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap index 69b1bd9011..d70f77e1e0 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -5,17 +5,13 @@ }, "description": "Get information on a specific pull request in GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner", - "repo", - "pullNumber" - ], "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": { - "type": "string", - "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n", + "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get combined commit status of a head commit in a pull request.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", "enum": [ "get", "get_diff", @@ -23,33 +19,42 @@ "get_files", "get_review_comments", "get_reviews", - "get_comments" - ] + "get_comments", + "get_check_runs" + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "pullNumber": { - "type": "number", - "description": "Pull request number" + "description": "Pull request number", + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "method", + "owner", + "repo", + "pullNumber" + ], + "type": "object" }, "name": "pull_request_read" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/pull_request_review_write.snap b/pkg/github/__toolsnaps__/pull_request_review_write.snap index 92cc199240..d4a7c30d32 100644 --- a/pkg/github/__toolsnaps__/pull_request_review_write.snap +++ b/pkg/github/__toolsnaps__/pull_request_review_write.snap @@ -1,56 +1,62 @@ { "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": { - "type": "object", - "required": [ - "method", - "owner", - "repo", - "pullNumber" - ], "properties": { "body": { - "type": "string", - "description": "Review comment text" + "description": "Review comment text", + "type": "string" }, "commitID": { - "type": "string", - "description": "SHA of commit to review" + "description": "SHA of commit to review", + "type": "string" }, "event": { - "type": "string", "description": "Review action to perform.", "enum": [ "APPROVE", "REQUEST_CHANGES", "COMMENT" - ] + ], + "type": "string" }, "method": { - "type": "string", "description": "The write operation to perform on pull request review.", "enum": [ "create", "submit_pending", - "delete_pending" - ] + "delete_pending", + "resolve_thread", + "unresolve_thread" + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "pullNumber": { - "type": "number", - "description": "Pull request number" + "description": "Pull request number", + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" + }, + "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": [ + "method", + "owner", + "repo", + "pullNumber" + ], + "type": "object" }, "name": "pull_request_review_write" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/push_files.snap b/pkg/github/__toolsnaps__/push_files.snap index 4db764cc94..df6c4d1e79 100644 --- a/pkg/github/__toolsnaps__/push_files.snap +++ b/pkg/github/__toolsnaps__/push_files.snap @@ -4,53 +4,54 @@ }, "description": "Push multiple files to a GitHub repository in a single commit", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "branch", - "files", - "message" - ], "properties": { "branch": { - "type": "string", - "description": "Branch to push to" + "description": "Branch to push to", + "type": "string" }, "files": { - "type": "array", "description": "Array of file objects to push, each object with path (string) and content (string)", "items": { - "type": "object", - "required": [ - "path", - "content" - ], + "additionalProperties": false, "properties": { "content": { - "type": "string", - "description": "file content" + "description": "file content", + "type": "string" }, "path": { - "type": "string", - "description": "path to the file" + "description": "path to the file", + "type": "string" } - } - } + }, + "required": [ + "path", + "content" + ], + "type": "object" + }, + "type": "array" }, "message": { - "type": "string", - "description": "Commit message" + "description": "Commit message", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "branch", + "files", + "message" + ], + "type": "object" }, "name": "push_files" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/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_copilot_review.snap b/pkg/github/__toolsnaps__/request_copilot_review.snap index 0bf419d98b..cd00f73fd4 100644 --- a/pkg/github/__toolsnaps__/request_copilot_review.snap +++ b/pkg/github/__toolsnaps__/request_copilot_review.snap @@ -3,39 +3,39 @@ "title": "Request Copilot review" }, "description": "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer.", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "pullNumber" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "pullNumber": { - "type": "number", - "description": "Pull request number" - }, - "repo": { - "type": "string", - "description": "Repository name" - } - } - }, - "name": "request_copilot_review", "icons": [ { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC20lEQVRIidWUS4wMURSGv3O7kWmPEMRrSMzcbl1dpqtmGuOxsCKECCKxEBusSJhIWEhsWLFAbC1sWFiISBARCyQ2kzSZGaMxHokgXvGIiMH0PRZjpJqqHpb+TeX+59z//H/q5sD/DqlX9H1/zFeX2qzIKoFWYDKgwBtUymL0UkNaT3V3d3/+5wG2EGxB9TDIxGFMvhVhb9/drpN/NaDJC7MGdwJk6TDCv0Gvq0lve9R762GUNdFDLleaZNBrICGq+4yhvf9TJtP/KZNB2PrLlbBliBfRhajuAwnFVa/n8/nkxFkv3GO9oJrzgwVxdesV71ov6I2r5fxggfWCatYL9yYmUJgLPH7Q29WZ4OED6Me4wuAdeQK6MMqna9t0GuibBHFAmgZ9JMG9BhkXZWoSCDSATIq7aguBD0wBplq/tZBgYDIwKnZAs99mFRYD9vd/YK0dpcqhobM6d9haWyOULRTbAauwuNlvsxHTYP3iBnVyXGAa8BIYC3oVeAKioCtAPEE7FCOgR0ErIJdBBZgNskzh40+NF6K6s+9e91lp9osrxMnFoTSmSmPVsF+E5cB0YEDgtoMjjypd5wCy+WC9GnajhEAa4bkqV9LOHKwa9/yneYeyUqwX3AdyQ5EeVrrqro/hYL0g+ggemKh4HGbPmVu0+fB8U76lpR6XgJwZpoGUpNYiusZg1tXjkmCAav0OMTXfJC4eVYPqwbot6l4BCPqyLhd7lwMAWC/cYb3gi/UCzRaKOxsbFzVEM1iv2Ebt5v2Dm14qZbJecZf1Ah3UCrcTbbB+awHnjgHLgHeinHYqZ8aPSXWWy+XvcQZLpdKI9/0D7UbZiLIJmABckVSqo+/OrUrNgF+D8q1LEdcBrAJGAJ8ROlGeicorABWdAswE5gOjge8CF8Ad66v03IjqJb75WS0tE0YOmNWqLBGReaAzgIkMLrt3oM9UpSzCzW9pd+FpT8/7JK3/Gz8Ao5X6wtwP7N4AAAAASUVORK5CYII=", "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC20lEQVRIidWUS4wMURSGv3O7kWmPEMRrSMzcbl1dpqtmGuOxsCKECCKxEBusSJhIWEhsWLFAbC1sWFiISBARCyQ2kzSZGaMxHokgXvGIiMH0PRZjpJqqHpb+TeX+59z//H/q5sD/DqlX9H1/zFeX2qzIKoFWYDKgwBtUymL0UkNaT3V3d3/+5wG2EGxB9TDIxGFMvhVhb9/drpN/NaDJC7MGdwJk6TDCv0Gvq0lve9R762GUNdFDLleaZNBrICGq+4yhvf9TJtP/KZNB2PrLlbBliBfRhajuAwnFVa/n8/nkxFkv3GO9oJrzgwVxdesV71ov6I2r5fxggfWCatYL9yYmUJgLPH7Q29WZ4OED6Me4wuAdeQK6MMqna9t0GuibBHFAmgZ9JMG9BhkXZWoSCDSATIq7aguBD0wBplq/tZBgYDIwKnZAs99mFRYD9vd/YK0dpcqhobM6d9haWyOULRTbAauwuNlvsxHTYP3iBnVyXGAa8BIYC3oVeAKioCtAPEE7FCOgR0ErIJdBBZgNskzh40+NF6K6s+9e91lp9osrxMnFoTSmSmPVsF+E5cB0YEDgtoMjjypd5wCy+WC9GnajhEAa4bkqV9LOHKwa9/yneYeyUqwX3AdyQ5EeVrrqro/hYL0g+ggemKh4HGbPmVu0+fB8U76lpR6XgJwZpoGUpNYiusZg1tXjkmCAav0OMTXfJC4eVYPqwbot6l4BCPqyLhd7lwMAWC/cYb3gi/UCzRaKOxsbFzVEM1iv2Ebt5v2Dm14qZbJecZf1Ah3UCrcTbbB+awHnjgHLgHeinHYqZ8aPSXWWy+XvcQZLpdKI9/0D7UbZiLIJmABckVSqo+/OrUrNgF+D8q1LEdcBrAJGAJ8ROlGeicorABWdAswE5gOjge8CF8Ad66v03IjqJb75WS0tE0YOmNWqLBGReaAzgIkMLrt3oM9UpSzCzW9pd+FpT8/7JK3/Gz8Ao5X6wtwP7N4AAAAASUVORK5CYII=", "theme": "light" }, { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACCElEQVRIid2UPWsUYRSFn3dxWWJUkESiBgslFokfhehGiGClBBQx4h9IGlEh2ijYxh+gxEL/hIWwhYpF8KNZsFRJYdJEiUbjCkqisj4W+y6Mk5nd1U4PDMOce+45L3fmDvzXUDeo59WK+kb9rn5TF9R76jm1+2/NJ9QPtseSOv4nxrvVmQ6M05hRB9qZ98ZR1NRralntitdEwmw8wQ9HbS329rQKuKLW1XJO/aX6IqdWjr1Xk/y6lG4vMBdCqOacoZZ3uBBCVZ0HDrcK2AYs5ZkAuwBb1N8Dm5JEISXoAnqzOtU9QB+wVR3KCdgClDIr6kCc4c/0O1BLNnahiYpaSmmGY62e/JpCLJ4FpmmMaBHYCDwC5mmMZBQYBC7HnhvAK+B+fN4JHAM+R4+3wGQI4S7qaExtol+9o86pq+oX9Yk6ljjtGfVprK2qr9Xb6vaET109jjqb3Jac2XaM1PLNpok1Aep+G/+dfa24nADTX1EWTgOngLE2XCYKQL0DTfKex2WhXgCutxG9i/fFNlwWpgBQL6orcWyTaldToRbUA2pow61XL0WPFfXCb1HqkPowCj6q0+qIWsw7nlpUj6i31OXY+0AdbGpCRtNRGgt1AigCX4EqsJAYTR+wAzgEdAM/gApwM4TwOOm3JiARtBk4CYwAB4F+oIfGZi/HwOfAM6ASQviU5/Vv4xcBzmW2eT1nrQAAAABJRU5ErkJggg==", "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACCElEQVRIid2UPWsUYRSFn3dxWWJUkESiBgslFokfhehGiGClBBQx4h9IGlEh2ijYxh+gxEL/hIWwhYpF8KNZsFRJYdJEiUbjCkqisj4W+y6Mk5nd1U4PDMOce+45L3fmDvzXUDeo59WK+kb9rn5TF9R76jm1+2/NJ9QPtseSOv4nxrvVmQ6M05hRB9qZ98ZR1NRralntitdEwmw8wQ9HbS329rQKuKLW1XJO/aX6IqdWjr1Xk/y6lG4vMBdCqOacoZZ3uBBCVZ0HDrcK2AYs5ZkAuwBb1N8Dm5JEISXoAnqzOtU9QB+wVR3KCdgClDIr6kCc4c/0O1BLNnahiYpaSmmGY62e/JpCLJ4FpmmMaBHYCDwC5mmMZBQYBC7HnhvAK+B+fN4JHAM+R4+3wGQI4S7qaExtol+9o86pq+oX9Yk6ljjtGfVprK2qr9Xb6vaET109jjqb3Jac2XaM1PLNpok1Aep+G/+dfa24nADTX1EWTgOngLE2XCYKQL0DTfKex2WhXgCutxG9i/fFNlwWpgBQL6orcWyTaldToRbUA2pow61XL0WPFfXCb1HqkPowCj6q0+qIWsw7nlpUj6i31OXY+0AdbGpCRtNRGgt1AigCX4EqsJAYTR+wAzgEdAM/gApwM4TwOOm3JiARtBk4CYwAB4F+oIfGZi/HwOfAM6ASQviU5/Vv4xcBzmW2eT1nrQAAAABJRU5ErkJggg==", "theme": "dark" } - ] + ], + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner", + "type": "string" + }, + "pullNumber": { + "description": "Pull request number", + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "pullNumber" + ], + "type": "object" + }, + "name": "request_copilot_review" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap b/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap new file mode 100644 index 0000000000..67b7014474 --- /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 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 2c627637cc..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": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "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 00514ee79d..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": { - "type": "object", - "required": [ - "owner", - "repo", - "run_id" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "run_id": { - "type": "number", - "description": "The unique identifier of the workflow run" - } - } - }, - "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 bb35e82132..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": { - "type": "object", - "required": [ - "owner", - "repo", - "workflow_id", - "ref" - ], - "properties": { - "inputs": { - "type": "object", - "description": "Inputs the workflow accepts" - }, - "owner": { - "type": "string", - "description": "Repository owner" - }, - "ref": { - "type": "string", - "description": "The git reference for the workflow. The reference can be a branch or tag name." - }, - "repo": { - "type": "string", - "description": "Repository name" - }, - "workflow_id": { - "type": "string", - "description": "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml)" - } - } - }, - "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 aebd432bfb..79cbbf04e9 100644 --- a/pkg/github/__toolsnaps__/search_code.snap +++ b/pkg/github/__toolsnaps__/search_code.snap @@ -5,39 +5,39 @@ }, "description": "Fast and precise code search across ALL GitHub repositories using GitHub's native search engine. Best for finding exact symbols, functions, classes, or specific code patterns.", "inputSchema": { - "type": "object", - "required": [ - "query" - ], "properties": { "order": { - "type": "string", "description": "Sort order for results", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "query": { - "type": "string", - "description": "Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more." + "description": "Search query (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": { - "type": "string", - "description": "Sort field ('indexed' only)" + "description": "Sort field ('indexed' only)", + "type": "string" } - } + }, + "required": [ + "query" + ], + "type": "object" }, "name": "search_code" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_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__/search_issues.snap b/pkg/github/__toolsnaps__/search_issues.snap index f76a715fb1..beaa5b7376 100644 --- a/pkg/github/__toolsnaps__/search_issues.snap +++ b/pkg/github/__toolsnaps__/search_issues.snap @@ -5,44 +5,39 @@ }, "description": "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue", "inputSchema": { - "type": "object", - "required": [ - "query" - ], "properties": { "order": { - "type": "string", "description": "Sort order", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Optional repository owner. If provided with repo, only issues for this repository are listed." + "description": "Optional repository owner. If provided with repo, only issues for this repository are listed.", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "query": { - "type": "string", - "description": "Search query using GitHub issues search syntax" + "description": "Search query using GitHub issues search syntax", + "type": "string" }, "repo": { - "type": "string", - "description": "Optional repository name. If provided with owner, only issues for this repository are listed." + "description": "Optional repository name. If provided with owner, only issues for this repository are listed.", + "type": "string" }, "sort": { - "type": "string", "description": "Sort field by number of matches of categories, defaults to best match", "enum": [ "comments", @@ -56,9 +51,14 @@ "interactions", "created", "updated" - ] + ], + "type": "string" } - } + }, + "required": [ + "query" + ], + "type": "object" }, "name": "search_issues" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_orgs.snap b/pkg/github/__toolsnaps__/search_orgs.snap index 36eb948aec..9670a4be8f 100644 --- a/pkg/github/__toolsnaps__/search_orgs.snap +++ b/pkg/github/__toolsnaps__/search_orgs.snap @@ -5,44 +5,44 @@ }, "description": "Find GitHub organizations by name, location, or other organization metadata. Ideal for discovering companies, open source foundations, or teams.", "inputSchema": { - "type": "object", - "required": [ - "query" - ], "properties": { "order": { - "type": "string", "description": "Sort order", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "query": { - "type": "string", - "description": "Organization search query. Examples: 'microsoft', 'location:california', 'created:\u003e=2025-01-01'. Search is automatically scoped to type:org." + "description": "Organization search query. Examples: 'microsoft', 'location:california', 'created:\u003e=2025-01-01'. Search is automatically scoped to type:org.", + "type": "string" }, "sort": { - "type": "string", "description": "Sort field by category", "enum": [ "followers", "repositories", "joined" - ] + ], + "type": "string" } - } + }, + "required": [ + "query" + ], + "type": "object" }, "name": "search_orgs" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_pull_requests.snap b/pkg/github/__toolsnaps__/search_pull_requests.snap index 2013f5c085..05376c0065 100644 --- a/pkg/github/__toolsnaps__/search_pull_requests.snap +++ b/pkg/github/__toolsnaps__/search_pull_requests.snap @@ -5,44 +5,39 @@ }, "description": "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr", "inputSchema": { - "type": "object", - "required": [ - "query" - ], "properties": { "order": { - "type": "string", "description": "Sort order", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "owner": { - "type": "string", - "description": "Optional repository owner. If provided with repo, only pull requests for this repository are listed." + "description": "Optional repository owner. If provided with repo, only pull requests for this repository are listed.", + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "query": { - "type": "string", - "description": "Search query using GitHub pull request search syntax" + "description": "Search query using GitHub pull request search syntax", + "type": "string" }, "repo": { - "type": "string", - "description": "Optional repository name. If provided with owner, only pull requests for this repository are listed." + "description": "Optional repository name. If provided with owner, only pull requests for this repository are listed.", + "type": "string" }, "sort": { - "type": "string", "description": "Sort field by number of matches of categories, defaults to best match", "enum": [ "comments", @@ -56,9 +51,14 @@ "interactions", "created", "updated" - ] + ], + "type": "string" } - } + }, + "required": [ + "query" + ], + "type": "object" }, "name": "search_pull_requests" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_repositories.snap b/pkg/github/__toolsnaps__/search_repositories.snap index 881bc3816b..8e1cb31714 100644 --- a/pkg/github/__toolsnaps__/search_repositories.snap +++ b/pkg/github/__toolsnaps__/search_repositories.snap @@ -5,50 +5,50 @@ }, "description": "Find GitHub repositories by name, description, readme, topics, or other metadata. Perfect for discovering projects, finding examples, or locating specific repositories across GitHub.", "inputSchema": { - "type": "object", - "required": [ - "query" - ], "properties": { "minimal_output": { - "type": "boolean", + "default": true, "description": "Return minimal repository information (default: true). When false, returns full GitHub API repository objects.", - "default": true + "type": "boolean" }, "order": { - "type": "string", "description": "Sort order", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "query": { - "type": "string", - "description": "Repository search query. Examples: 'machine learning in:name stars:\u003e1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering." + "description": "Repository search query. Examples: 'machine learning in:name stars:\u003e1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering.", + "type": "string" }, "sort": { - "type": "string", "description": "Sort repositories by field, defaults to best match", "enum": [ "stars", "forks", "help-wanted-issues", "updated" - ] + ], + "type": "string" } - } + }, + "required": [ + "query" + ], + "type": "object" }, "name": "search_repositories" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_users.snap b/pkg/github/__toolsnaps__/search_users.snap index 293107696c..bed86e8c6c 100644 --- a/pkg/github/__toolsnaps__/search_users.snap +++ b/pkg/github/__toolsnaps__/search_users.snap @@ -5,44 +5,44 @@ }, "description": "Find GitHub users by username, real name, or other profile information. Useful for locating developers, contributors, or team members.", "inputSchema": { - "type": "object", - "required": [ - "query" - ], "properties": { "order": { - "type": "string", "description": "Sort order", "enum": [ "asc", "desc" - ] + ], + "type": "string" }, "page": { - "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1 + "minimum": 1, + "type": "number" }, "perPage": { - "type": "number", "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, "minimum": 1, - "maximum": 100 + "type": "number" }, "query": { - "type": "string", - "description": "User search query. Examples: 'john smith', 'location:seattle', 'followers:\u003e100'. Search is automatically scoped to type:user." + "description": "User search query. Examples: 'john smith', 'location:seattle', 'followers:\u003e100'. Search is automatically scoped to type:user.", + "type": "string" }, "sort": { - "type": "string", "description": "Sort users by number of followers or repositories, or when the person joined GitHub.", "enum": [ "followers", "repositories", "joined" - ] + ], + "type": "string" } - } + }, + "required": [ + "query" + ], + "type": "object" }, "name": "search_users" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/set_issue_fields.snap b/pkg/github/__toolsnaps__/set_issue_fields.snap new file mode 100644 index 0000000000..88c88fdc65 --- /dev/null +++ b/pkg/github/__toolsnaps__/set_issue_fields.snap @@ -0,0 +1,79 @@ +{ + "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": { + "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__/star_repository.snap b/pkg/github/__toolsnaps__/star_repository.snap index ab1514b3de..3d7088939b 100644 --- a/pkg/github/__toolsnaps__/star_repository.snap +++ b/pkg/github/__toolsnaps__/star_repository.snap @@ -3,34 +3,34 @@ "title": "Star repository" }, "description": "Star a GitHub repository", - "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], - "properties": { - "owner": { - "type": "string", - "description": "Repository owner" - }, - "repo": { - "type": "string", - "description": "Repository name" - } - } - }, - "name": "star_repository", "icons": [ { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACG0lEQVRIidWVMWgTYRiGn+/+a21EClGrERRiTWLShrbiUETErDq7u3QRF0WhoKN06uYgKEVx1lGQLjo4OTUJ2FzSpBrEQiCkGYPm7nMwlZBe2rvaxXf6eb//fd//u/+7O0MIJDJz905MnJpvNRufg2oksHli5iwjUgXExUp9La3Vg+isoAGMyiJwBBi11XsQVBaog0zm8plfdGtApEd1LJdEpVL4sZ82UAc/cRf7zAHGPKMPg2j37eB8NnvauGYTODpQ6hjPulAur23tpTd7FePx+JhtIkvAVZ+yraJj48ciH9rtdneYhwCk03NxV5hWNAWSVLykIEngHPs/Rg/4ruiGYG2AbghSMcoXx8l/k3R6Lt4V3STEyAaE2iqTluPk66Arh2wO6Irj5OsGoNVsvIuejEVFmD8Ua+V5zSneAfTvJW83G6vHJ2LjwJV/tH9Wc4p3AYWBKWo1G6vRiZgRuH4ga3S5Vire7+d2jel2s/HxICEKT2ql4qNB3ncEbU9fhTEHGFF56cf7BrhCNmyAi/pqhr1EoQN0iGZIgEyHDUDw1dghNneB1731bR9tsA5yuZwNZPooBd4YT7PVUmGhWios2CpJEV7w5zu0g0xPO3DWAUymZ1OWUO6V3yP6uLpeWPM7XWJq9hIqS6A3ADzl4qZTqPTv2ZUYMd2tjms/NZa+rawXPvkZ76AXfDM1NXPN9eRWxHT3/Df8n/gNrfGxihYBZk0AAAAASUVORK5CYII=", "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACG0lEQVRIidWVMWgTYRiGn+/+a21EClGrERRiTWLShrbiUETErDq7u3QRF0WhoKN06uYgKEVx1lGQLjo4OTUJ2FzSpBrEQiCkGYPm7nMwlZBe2rvaxXf6eb//fd//u/+7O0MIJDJz905MnJpvNRufg2oksHli5iwjUgXExUp9La3Vg+isoAGMyiJwBBi11XsQVBaog0zm8plfdGtApEd1LJdEpVL4sZ82UAc/cRf7zAHGPKMPg2j37eB8NnvauGYTODpQ6hjPulAur23tpTd7FePx+JhtIkvAVZ+yraJj48ciH9rtdneYhwCk03NxV5hWNAWSVLykIEngHPs/Rg/4ruiGYG2AbghSMcoXx8l/k3R6Lt4V3STEyAaE2iqTluPk66Arh2wO6Irj5OsGoNVsvIuejEVFmD8Ua+V5zSneAfTvJW83G6vHJ2LjwJV/tH9Wc4p3AYWBKWo1G6vRiZgRuH4ga3S5Vire7+d2jel2s/HxICEKT2ql4qNB3ncEbU9fhTEHGFF56cf7BrhCNmyAi/pqhr1EoQN0iGZIgEyHDUDw1dghNneB1731bR9tsA5yuZwNZPooBd4YT7PVUmGhWios2CpJEV7w5zu0g0xPO3DWAUymZ1OWUO6V3yP6uLpeWPM7XWJq9hIqS6A3ADzl4qZTqPTv2ZUYMd2tjms/NZa+rawXPvkZ76AXfDM1NXPN9eRWxHT3/Df8n/gNrfGxihYBZk0AAAAASUVORK5CYII=", "theme": "light" }, { - "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABLElEQVRIidWVTUrDQBiG3ylddNG9+IdRMC7qpgfoWTyCnqFLF/62UMGNWw/gTdwoiMUzaCtoHxeZ4BQn6SQdEV8IZPG9zzMzCYlUIcARcFilUwW+AUyBd2DrNwSXfOciNnwVeHMEE2A9puCMnzmNBV8BXj2CCbC2LLwFDDzwPAOgVcYwFpRI6khKJe0616akxoJ1zCS9SHp0rgdJ98aYZ2PhT7ksYpC005A0lnQdGS7LHGcqMMB5yVlXzQiYP1orOYkAHwLFxw30l4AfBx1eTUk/+OkA2zUEiY9V9I7vB69mQefPBJ0aAm+nWWH4Q9KNvT/wdMN2DTTJ/lx5ZsAtsOfMJMAV8OnMTYGiBc8JUqd0B3RLZrt2Jk8aImiTfTZ6QVvOOj3baYd2/k++AC+3Yx0GcXS0AAAAAElFTkSuQmCC", "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABLElEQVRIidWVTUrDQBiG3ylddNG9+IdRMC7qpgfoWTyCnqFLF/62UMGNWw/gTdwoiMUzaCtoHxeZ4BQn6SQdEV8IZPG9zzMzCYlUIcARcFilUwW+AUyBd2DrNwSXfOciNnwVeHMEE2A9puCMnzmNBV8BXj2CCbC2LLwFDDzwPAOgVcYwFpRI6khKJe0616akxoJ1zCS9SHp0rgdJ98aYZ2PhT7ksYpC005A0lnQdGS7LHGcqMMB5yVlXzQiYP1orOYkAHwLFxw30l4AfBx1eTUk/+OkA2zUEiY9V9I7vB69mQefPBJ0aAm+nWWH4Q9KNvT/wdMN2DTTJ/lx5ZsAtsOfMJMAV8OnMTYGiBc8JUqd0B3RLZrt2Jk8aImiTfTZ6QVvOOj3baYd2/k++AC+3Yx0GcXS0AAAAAElFTkSuQmCC", "theme": "dark" } - ] + ], + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "star_repository" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/sub_issue_write.snap b/pkg/github/__toolsnaps__/sub_issue_write.snap index 1c721a2bb7..1e4fcceabf 100644 --- a/pkg/github/__toolsnaps__/sub_issue_write.snap +++ b/pkg/github/__toolsnaps__/sub_issue_write.snap @@ -4,48 +4,48 @@ }, "description": "Add a sub-issue to a parent issue in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "method", - "owner", - "repo", - "issue_number", - "sub_issue_id" - ], "properties": { "after_id": { - "type": "number", - "description": "The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)" + "description": "The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)", + "type": "number" }, "before_id": { - "type": "number", - "description": "The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)" + "description": "The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)", + "type": "number" }, "issue_number": { - "type": "number", - "description": "The number of the parent issue" + "description": "The number of the parent issue", + "type": "number" }, "method": { - "type": "string", - "description": "The action to perform on a single sub-issue\nOptions are:\n- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n\t\t\t\t" + "description": "The action to perform on a single sub-issue\nOptions are:\n- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n\t\t\t\t", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "replace_parent": { - "type": "boolean", - "description": "When true, replaces the sub-issue's current parent issue. Use with 'add' method only." + "description": "When true, replaces the sub-issue's current parent issue. Use with 'add' method only.", + "type": "boolean" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "sub_issue_id": { - "type": "number", - "description": "The ID of the sub-issue to add. ID is not the same as issue number" + "description": "The ID of the sub-issue to add. ID is not the same as issue number", + "type": "number" } - } + }, + "required": [ + "method", + "owner", + "repo", + "issue_number", + "sub_issue_id" + ], + "type": "object" }, "name": "sub_issue_write" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap b/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap new file mode 100644 index 0000000000..81223e2a9d --- /dev/null +++ b/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap @@ -0,0 +1,46 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Submit Pending Pull Request Review" + }, + "description": "Submit a pending pull request review.", + "inputSchema": { + "properties": { + "body": { + "description": "The review body text (optional)", + "type": "string" + }, + "event": { + "description": "The review action to perform", + "enum": [ + "APPROVE", + "REQUEST_CHANGES", + "COMMENT" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "pullNumber": { + "description": "The pull request number", + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "pullNumber", + "event" + ], + "type": "object" + }, + "name": "submit_pending_pull_request_review" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unresolve_review_thread.snap b/pkg/github/__toolsnaps__/unresolve_review_thread.snap new file mode 100644 index 0000000000..d58ba31a6f --- /dev/null +++ b/pkg/github/__toolsnaps__/unresolve_review_thread.snap @@ -0,0 +1,21 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Unresolve Review Thread" + }, + "description": "Unresolve a previously resolved review thread on a pull request. Unresolving an already-unresolved thread is a no-op.", + "inputSchema": { + "properties": { + "threadID": { + "description": "The node ID of the review thread to unresolve (e.g., PRRT_kwDOxxx)", + "type": "string" + } + }, + "required": [ + "threadID" + ], + "type": "object" + }, + "name": "unresolve_review_thread" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unstar_repository.snap b/pkg/github/__toolsnaps__/unstar_repository.snap index 709453650f..2bb5d68259 100644 --- a/pkg/github/__toolsnaps__/unstar_repository.snap +++ b/pkg/github/__toolsnaps__/unstar_repository.snap @@ -4,21 +4,21 @@ }, "description": "Unstar a GitHub repository", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo" - ], "properties": { "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" }, "name": "unstar_repository" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_gist.snap b/pkg/github/__toolsnaps__/update_gist.snap index a3907a88c8..6d5ed100e4 100644 --- a/pkg/github/__toolsnaps__/update_gist.snap +++ b/pkg/github/__toolsnaps__/update_gist.snap @@ -4,30 +4,30 @@ }, "description": "Update an existing gist", "inputSchema": { - "type": "object", - "required": [ - "gist_id", - "filename", - "content" - ], "properties": { "content": { - "type": "string", - "description": "Content for the file" + "description": "Content for the file", + "type": "string" }, "description": { - "type": "string", - "description": "Updated description of the gist" + "description": "Updated description of the gist", + "type": "string" }, "filename": { - "type": "string", - "description": "Filename to update or create" + "description": "Filename to update or create", + "type": "string" }, "gist_id": { - "type": "string", - "description": "ID of the gist to update" + "description": "ID of the gist to update", + "type": "string" } - } + }, + "required": [ + "gist_id", + "filename", + "content" + ], + "type": "object" }, "name": "update_gist" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_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..3bdbdfc9ef --- /dev/null +++ b/pkg/github/__toolsnaps__/update_issue_labels.snap @@ -0,0 +1,66 @@ +{ + "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.", + "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": { + "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..da749cd466 --- /dev/null +++ b/pkg/github/__toolsnaps__/update_issue_type.snap @@ -0,0 +1,46 @@ +{ + "annotations": { + "destructiveHint": false, + "openWorldHint": true, + "title": "Update Issue Type" + }, + "description": "Update the type of an existing issue (e.g. 'bug', 'feature').", + "inputSchema": { + "properties": { + "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 8f5afaa583..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": { - "type": "object", - "required": [ - "owner_type", - "owner", - "project_number", - "item_id", - "updated_field" - ], - "properties": { - "item_id": { - "type": "number", - "description": "The unique identifier of the project item. This is not the issue or pull request ID." - }, - "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." - }, - "owner_type": { - "type": "string", - "description": "Owner type", - "enum": [ - "user", - "org" - ] - }, - "project_number": { - "type": "number", - "description": "The project's number." - }, - "updated_field": { - "type": "object", - "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}" - } - } - }, - "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 6dec2c01f6..ef330188ff 100644 --- a/pkg/github/__toolsnaps__/update_pull_request.snap +++ b/pkg/github/__toolsnaps__/update_pull_request.snap @@ -4,61 +4,61 @@ }, "description": "Update an existing pull request in a GitHub repository.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "pullNumber" - ], "properties": { "base": { - "type": "string", - "description": "New base branch name" + "description": "New base branch name", + "type": "string" }, "body": { - "type": "string", - "description": "New description" + "description": "New description", + "type": "string" }, "draft": { - "type": "boolean", - "description": "Mark pull request as draft (true) or ready for review (false)" + "description": "Mark pull request as draft (true) or ready for review (false)", + "type": "boolean" }, "maintainer_can_modify": { - "type": "boolean", - "description": "Allow maintainer edits" + "description": "Allow maintainer edits", + "type": "boolean" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "pullNumber": { - "type": "number", - "description": "Pull request number to update" + "description": "Pull request number to update", + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" }, "reviewers": { - "type": "array", "description": "GitHub usernames to request reviews from", "items": { "type": "string" - } + }, + "type": "array" }, "state": { - "type": "string", "description": "New state", "enum": [ "open", "closed" - ] + ], + "type": "string" }, "title": { - "type": "string", - "description": "New title" + "description": "New title", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "pullNumber" + ], + "type": "object" }, "name": "update_pull_request" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_pull_request_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_branch.snap b/pkg/github/__toolsnaps__/update_pull_request_branch.snap index 9be1cb0029..a84ac414dd 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_branch.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_branch.snap @@ -4,30 +4,30 @@ }, "description": "Update the branch of a pull request with the latest changes from the base branch.", "inputSchema": { - "type": "object", - "required": [ - "owner", - "repo", - "pullNumber" - ], "properties": { "expectedHeadSha": { - "type": "string", - "description": "The expected SHA of the pull request's HEAD ref" + "description": "The expected SHA of the pull request's HEAD ref", + "type": "string" }, "owner": { - "type": "string", - "description": "Repository owner" + "description": "Repository owner", + "type": "string" }, "pullNumber": { - "type": "number", - "description": "Pull request number" + "description": "Pull request number", + "type": "number" }, "repo": { - "type": "string", - "description": "Repository name" + "description": "Repository name", + "type": "string" } - } + }, + "required": [ + "owner", + "repo", + "pullNumber" + ], + "type": "object" }, "name": "update_pull_request_branch" } \ No newline at end of file diff --git a/pkg/github/__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 14cb8028ca..a7ce039d83 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -16,7 +16,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -26,10 +26,6 @@ const ( DescriptionRepositoryName = "Repository name" ) -// FeatureFlagConsolidatedActions is the feature flag that disables individual actions tools -// in favor of the consolidated actions tools. -const FeatureFlagConsolidatedActions = "remote_mcp_consolidated_actions" - // Method constants for consolidated actions tools const ( actionsMethodListWorkflows = "list_workflows" @@ -49,1394 +45,155 @@ 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.FeatureFlagDisable = FeatureFlagConsolidatedActions - 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.FeatureFlagDisable = FeatureFlagConsolidatedActions - 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.FeatureFlagDisable = FeatureFlagConsolidatedActions - 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) - - 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.FeatureFlagDisable = FeatureFlagConsolidatedActions - 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.FeatureFlagDisable = FeatureFlagConsolidatedActions - 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.FeatureFlagDisable = FeatureFlagConsolidatedActions - 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.FeatureFlagDisable = FeatureFlagConsolidatedActions - 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() }() +// 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) - } - } - - if len(failedJobs) == 0 { - result := map[string]any{ - "message": "No failed jobs found in this workflow run", - "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.FeatureFlagDisable = FeatureFlagConsolidatedActions - 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.FeatureFlagDisable = FeatureFlagConsolidatedActions - 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.FeatureFlagDisable = FeatureFlagConsolidatedActions - 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) - - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + if job.GetConclusion() == "failure" { + failedJobs = append(failedJobs, job) + } + } - // Set up list options - opts := &github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - } + if len(failedJobs) == 0 { + result := map[string]any{ + "message": "No failed jobs found in this workflow run", + "run_id": runID, + "total_jobs": len(jobs.Jobs), + "failed_jobs": 0, + } + r, _ := json.Marshal(result) + return utils.NewToolResultText(string(r)), nil, nil + } - 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 + // 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(), } - defer func() { _ = resp.Body.Close() }() + // Enable reporting of status codes and error causes + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err) // Explicitly ignore error for graceful handling + } - r, err := json.Marshal(artifacts) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } + logResults = append(logResults, jobResult) + } - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedActions - return tool -} + 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}, + } -// 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 - } + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } - 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) + return utils.NewToolResultText(string(r)), nil, nil +} - // 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.FeatureFlagDisable = FeatureFlagConsolidatedActions - 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.FeatureFlagDisable = FeatureFlagConsolidatedActions - 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.FeatureFlagDisable = FeatureFlagConsolidatedActions - return tool + _ = finish(len(lines), int64(len(finalResult))) + + return finalResult, totalLines, httpResp, nil } // ActionsList returns the tool and handler for listing GitHub Actions resources. @@ -1631,7 +388,6 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an } }, ) - tool.FeatureFlagEnable = FeatureFlagConsolidatedActions return tool } @@ -1740,7 +496,6 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an } }, ) - tool.FeatureFlagEnable = FeatureFlagConsolidatedActions return tool } @@ -1789,6 +544,7 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo "inputs": { Type: "object", Description: "Inputs the workflow accepts. Only used for 'run_workflow' method.", + Properties: map[string]*jsonschema.Schema{}, }, "run_id": { Type: "number", @@ -1819,11 +575,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 +613,6 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo } }, ) - tool.FeatureFlagEnable = FeatureFlagConsolidatedActions return tool } @@ -1948,8 +701,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 } @@ -1977,7 +730,6 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i 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 = FeatureFlagConsolidatedActions return tool } @@ -2226,7 +978,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 +989,10 @@ func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workfl var workflowType string if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { - resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) + _, resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) workflowType = "workflow_id" } else { - resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) + _, resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) workflowType = "workflow_file" } diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 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_scanning.go b/pkg/github/code_scanning.go index ccc00661a5..44307513bb 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -11,7 +11,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -94,6 +94,41 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server } func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "state": { + Type: "string", + Description: "Filter code scanning alerts by state. Defaults to open", + Enum: []any{"open", "closed", "dismissed", "fixed"}, + Default: json.RawMessage(`"open"`), + }, + "ref": { + Type: "string", + Description: "The Git reference for the results you want to list.", + }, + "severity": { + Type: "string", + Description: "Filter code scanning alerts by severity", + Enum: []any{"critical", "high", "medium", "low", "warning", "note", "error"}, + }, + "tool_name": { + Type: "string", + Description: "The name of the tool used for code scanning.", + }, + }, + Required: []string{"owner", "repo"}, + } + WithPagination(schema) + return NewTool( ToolsetMetadataCodeSecurity, mcp.Tool{ @@ -103,39 +138,7 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv Title: t("TOOL_LIST_CODE_SCANNING_ALERTS_USER_TITLE", "List code scanning alerts"), ReadOnlyHint: true, }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "The owner of the repository.", - }, - "repo": { - Type: "string", - Description: "The name of the repository.", - }, - "state": { - Type: "string", - Description: "Filter code scanning alerts by state. Defaults to open", - Enum: []any{"open", "closed", "dismissed", "fixed"}, - Default: json.RawMessage(`"open"`), - }, - "ref": { - Type: "string", - Description: "The Git reference for the results you want to list.", - }, - "severity": { - Type: "string", - Description: "Filter code scanning alerts by severity", - Enum: []any{"critical", "high", "medium", "low", "warning", "note", "error"}, - }, - "tool_name": { - Type: "string", - Description: "The name of the tool used for code scanning.", - }, - }, - Required: []string{"owner", "repo"}, - }, + InputSchema: schema, }, []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -164,11 +167,25 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv return utils.NewToolResultError(err.Error()), nil, nil } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - alerts, resp, err := client.CodeScanning.ListAlertsForRepo(ctx, owner, repo, &github.AlertListOptions{Ref: ref, State: state, Severity: severity, ToolName: toolName}) + alerts, resp, err := client.CodeScanning.ListAlertsForRepo(ctx, owner, repo, &github.AlertListOptions{ + Ref: ref, + State: state, + Severity: severity, + ToolName: toolName, + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list alerts", diff --git a/pkg/github/code_scanning_test.go b/pkg/github/code_scanning_test.go index 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..4008c2f4aa 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -6,6 +6,7 @@ import ( "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -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,14 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { }, } - return MarshalledTextResult(minimalUser), nil, nil + result := MarshalledTextResult(minimalUser) + if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + if result.Meta == nil { + result.Meta = mcp.Meta{} + } + result.Meta["ifc"] = ifc.LabelGetMe() + } + return result, nil, nil }, ) } @@ -179,7 +196,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 { @@ -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), } diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 3f4261e719..ade54aba17 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -10,7 +10,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/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,70 @@ func Test_GetMe(t *testing.T) { } } +func Test_GetMe_IFC_FeatureFlag(t *testing.T) { + t.Parallel() + + serverTool := GetMe(translations.NullTranslationHelper) + + mockUser := &github.User{ + Login: github.Ptr("testuser"), + HTMLURL: github.Ptr("https://github.com/testuser"), + CreatedAt: &github.Timestamp{Time: time.Now()}, + } + mockedHTTPClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUser: mockResponse(t, http.StatusOK, mockUser), + }) + + depsWithIFCFeature := func(enabled bool) *BaseDeps { + return NewBaseDeps( + mustNewGHClient(t, mockedHTTPClient), nil, nil, nil, + translations.NullTranslationHelper, + FeatureFlags{}, + 0, + func(_ context.Context, flagName string) (bool, error) { + return flagName == FeatureFlagIFCLabels && enabled, nil + }, + stubExporters(), + ) + } + + t.Run("feature disabled omits ifc label from result meta", func(t *testing.T) { + deps := depsWithIFCFeature(false) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{}) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assert.Nil(t, result.Meta, "result meta should be nil when IFC labels are disabled") + }) + + t.Run("feature enabled includes ifc label in result meta", func(t *testing.T) { + deps := depsWithIFCFeature(true) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{}) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta, "result meta should be set when IFC labels are enabled") + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + + var ifcMap map[string]any + err = json.Unmarshal(ifcJSON, &ifcMap) + require.NoError(t, err) + + assert.Equal(t, "trusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) +} + func Test_GetTeams(t *testing.T) { t.Parallel() @@ -215,7 +280,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 +290,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 +300,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 +333,7 @@ func Test_GetTeams(t *testing.T) { name: "successful get teams", makeDeps: func() ToolDependencies { return BaseDeps{ - Client: github.NewClient(httpClientWithUser()), + Client: mustNewGHClient(t, httpClientWithUser()), GQLClient: gqlClientForTestuser(), } }, @@ -293,7 +358,7 @@ func Test_GetTeams(t *testing.T) { name: "no teams found", makeDeps: func() ToolDependencies { return BaseDeps{ - Client: github.NewClient(httpClientWithUser()), + Client: mustNewGHClient(t, httpClientWithUser()), GQLClient: gqlClientNoTeams(), } }, @@ -304,7 +369,7 @@ func Test_GetTeams(t *testing.T) { { name: "getting client fails", makeDeps: func() ToolDependencies { - return stubDeps{clientFn: stubClientFnErr("expected test error")} + return stubDeps{clientFn: stubClientFnErr("expected test error"), obsv: stubExporters()} }, requestArgs: map[string]any{}, expectToolError: true, @@ -314,7 +379,8 @@ func Test_GetTeams(t *testing.T) { name: "get user fails", makeDeps: func() ToolDependencies { return BaseDeps{ - Client: github.NewClient(httpClientUserFails()), + Client: mustNewGHClient(t, httpClientUserFails()), + Obsv: stubExporters(), } }, requestArgs: map[string]any{}, @@ -325,8 +391,9 @@ func Test_GetTeams(t *testing.T) { name: "getting GraphQL client fails", makeDeps: func() ToolDependencies { return stubDeps{ - clientFn: stubClientFnFromHTTP(httpClientWithUser()), + clientFn: stubClientFnFromHTTP(t, httpClientWithUser()), gqlClientFn: stubGQLClientFnErr("GraphQL client error"), + obsv: stubExporters(), } }, requestArgs: map[string]any{}, @@ -419,7 +486,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 +497,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 +536,7 @@ func Test_GetTeamMembers(t *testing.T) { }, { name: "getting GraphQL client fails", - deps: stubDeps{gqlClientFn: stubGQLClientFnErr("GraphQL client error")}, + deps: stubDeps{gqlClientFn: stubGQLClientFnErr("GraphQL client error"), obsv: stubExporters()}, requestArgs: map[string]any{ "org": "testorg", "team_slug": "testteam", diff --git a/pkg/github/copilot.go b/pkg/github/copilot.go 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..02023da69f 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -69,7 +69,7 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo alert, resp, err := client.Dependabot.GetRepoAlert(ctx, owner, repo, alertNumber) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to get alert with number '%d'", alertNumber), + dependabotErrMsg(fmt.Sprintf("failed to get alert with number '%d'", alertNumber), owner, repo, resp), resp, err, ), nil, nil @@ -95,6 +95,33 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo } func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "state": { + Type: "string", + Description: "Filter dependabot alerts by state. Defaults to open", + Enum: []any{"open", "fixed", "dismissed", "auto_dismissed"}, + Default: json.RawMessage(`"open"`), + }, + "severity": { + Type: "string", + Description: "Filter dependabot alerts by severity", + Enum: []any{"low", "medium", "high", "critical"}, + }, + }, + Required: []string{"owner", "repo"}, + } + WithPagination(schema) + return NewTool( ToolsetMetadataDependabot, mcp.Tool{ @@ -104,31 +131,7 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server Title: t("TOOL_LIST_DEPENDABOT_ALERTS_USER_TITLE", "List dependabot alerts"), ReadOnlyHint: true, }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "The owner of the repository.", - }, - "repo": { - Type: "string", - Description: "The name of the repository.", - }, - "state": { - Type: "string", - Description: "Filter dependabot alerts by state. Defaults to open", - Enum: []any{"open", "fixed", "dismissed", "auto_dismissed"}, - Default: json.RawMessage(`"open"`), - }, - "severity": { - Type: "string", - Description: "Filter dependabot alerts by severity", - Enum: []any{"low", "medium", "high", "critical"}, - }, - }, - Required: []string{"owner", "repo"}, - }, + InputSchema: schema, }, []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -149,6 +152,11 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server return utils.NewToolResultError(err.Error()), nil, nil } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, err @@ -157,10 +165,14 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, owner, repo, &github.ListAlertsOptions{ State: ToStringPtr(state), Severity: ToStringPtr(severity), + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), + dependabotErrMsg(fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), owner, repo, resp), resp, err, ), nil, nil @@ -184,3 +196,16 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server }, ) } + +// dependabotErrMsg enhances error messages for dependabot API failures by +// appending a hint about token permissions when the response indicates +// the token may lack access to the repository (403 or 404). +func dependabotErrMsg(base, owner, repo string, resp *github.Response) string { + if resp != nil && (resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound) { + return fmt.Sprintf("%s. Your token may not have access to Dependabot alerts on %s/%s. "+ + "To access Dependabot alerts, the token needs the 'security_events' scope or, for fine-grained tokens, "+ + "Dependabot alerts read permission for this specific repository.", + base, owner, repo) + } + return base +} diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go index e57405a8cb..7811483908 100644 --- a/pkg/github/dependabot_test.go +++ b/pkg/github/dependabot_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/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) @@ -140,7 +156,7 @@ func Test_ListDependabotAlerts(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedAlerts []*github.DependabotAlert expectedErrMsg string @@ -149,12 +165,14 @@ func Test_ListDependabotAlerts(t *testing.T) { name: "successful open alerts listing", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ - "state": "open", + "state": "open", + "page": "1", + "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "state": "open", @@ -167,11 +185,13 @@ func Test_ListDependabotAlerts(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ "severity": "high", + "page": "1", + "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&highSeverityAlert}), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "severity": "high", @@ -182,17 +202,39 @@ func Test_ListDependabotAlerts(t *testing.T) { { name: "successful all alerts listing", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{}).andThen( + GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, expectError: false, expectedAlerts: []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}, }, + { + name: "successful alerts listing with custom pagination", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "page": "3", + "per_page": "100", + }).andThen( + mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "page": float64(3), + "perPage": float64(100), + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&criticalAlert}, + }, { name: "alerts listing fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -201,18 +243,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) diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index b41bf0b876..1141fbce89 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -3,13 +3,22 @@ package github 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" ) @@ -21,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. @@ -67,16 +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. @@ -93,8 +121,17 @@ type BaseDeps struct { T translations.TranslationHelperFunc Flags FeatureFlags ContentWindowSize int + + // 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. +var _ ToolDependencies = (*BaseDeps)(nil) + // NewBaseDeps creates a BaseDeps with the provided clients and configuration. func NewBaseDeps( client *gogithub.Client, @@ -104,6 +141,8 @@ func NewBaseDeps( t translations.TranslationHelperFunc, flags FeatureFlags, contentWindowSize int, + featureChecker inventory.FeatureFlagChecker, + obsv observability.Exporters, ) *BaseDeps { return &BaseDeps{ Client: client, @@ -113,6 +152,8 @@ func NewBaseDeps( T: t, Flags: flags, ContentWindowSize: contentWindowSize, + featureChecker: featureChecker, + Obsv: obsv, } } @@ -132,17 +173,47 @@ 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. +func (d BaseDeps) IsFeatureEnabled(ctx context.Context, flagName string) bool { + if d.featureChecker == nil || flagName == "" { + return false + } + + enabled, err := d.featureChecker(ctx, flagName) + if err != nil { + // Log error but don't fail the tool - treat as disabled + fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", flagName, err) + return false + } + + return enabled +} + // NewTool creates a ServerTool that retrieves ToolDependencies from context at call time. // This avoids creating closures at registration time, which is important for performance // in servers that create a new server instance per request (like the remote server). @@ -182,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) }) @@ -190,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 new file mode 100644 index 0000000000..1d747cae47 --- /dev/null +++ b/pkg/github/dependencies_test.go @@ -0,0 +1,120 @@ +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() + + // Create a feature checker that returns true for "test_flag" + checker := func(_ context.Context, flagName string) (bool, error) { + return flagName == "test_flag", nil + } + + // Create deps with the checker using NewBaseDeps + deps := github.NewBaseDeps( + nil, // client + nil, // gqlClient + nil, // rawClient + nil, // repoAccessCache + translations.NullTranslationHelper, + github.FeatureFlags{}, + 0, // contentWindowSize + checker, // featureChecker + testExporters(), + ) + + // Test enabled flag + result := deps.IsFeatureEnabled(context.Background(), "test_flag") + assert.True(t, result, "Expected test_flag to be enabled") + + // Test disabled flag + result = deps.IsFeatureEnabled(context.Background(), "other_flag") + assert.False(t, result, "Expected other_flag to be disabled") +} + +func TestIsFeatureEnabled_WithoutChecker(t *testing.T) { + t.Parallel() + + // Create deps without feature checker (nil) + deps := github.NewBaseDeps( + nil, // client + nil, // gqlClient + nil, // rawClient + nil, // repoAccessCache + translations.NullTranslationHelper, + github.FeatureFlags{}, + 0, // contentWindowSize + nil, // featureChecker (nil) + testExporters(), + ) + + // Should return false when checker is nil + result := deps.IsFeatureEnabled(context.Background(), "any_flag") + assert.False(t, result, "Expected false when checker is nil") +} + +func TestIsFeatureEnabled_EmptyFlagName(t *testing.T) { + t.Parallel() + + // Create a feature checker + checker := func(_ context.Context, _ string) (bool, error) { + return true, nil + } + + deps := github.NewBaseDeps( + nil, // client + nil, // gqlClient + nil, // rawClient + nil, // repoAccessCache + translations.NullTranslationHelper, + github.FeatureFlags{}, + 0, // contentWindowSize + checker, // featureChecker + testExporters(), + ) + + // Should return false for empty flag name + result := deps.IsFeatureEnabled(context.Background(), "") + assert.False(t, result, "Expected false for empty flag name") +} + +func TestIsFeatureEnabled_CheckerError(t *testing.T) { + t.Parallel() + + // Create a feature checker that returns an error + checker := func(_ context.Context, _ string) (bool, error) { + return false, errors.New("checker error") + } + + deps := github.NewBaseDeps( + nil, // client + nil, // gqlClient + nil, // rawClient + nil, // repoAccessCache + translations.NullTranslationHelper, + github.FeatureFlags{}, + 0, // contentWindowSize + checker, // featureChecker + testExporters(), + ) + + // Should return false and log error (not crash) + result := deps.IsFeatureEnabled(context.Background(), "error_flag") + assert.False(t, result, "Expected false when checker returns error") +} diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index c036708187..514a2d030d 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -4,13 +4,14 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/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 +215,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 +257,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), @@ -313,7 +314,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 +339,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 +353,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 +361,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), }, } @@ -405,6 +406,10 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve Type: "number", Description: "Discussion Number", }, + "includeReplies": { + Type: "boolean", + Description: "When true, each top-level comment will include its replies nested within it (up to 100 replies per comment, which is the GitHub API maximum). Defaults to false.", + }, }, Required: []string{"owner", "repo", "discussionNumber"}, }), @@ -417,7 +422,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 +457,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 +468,111 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve } else { vars["after"] = (*githubv4.String)(nil) } - if err := client.Query(ctx, &q, vars); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + + var comments []MinimalDiscussionComment + var pageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String } + var totalCount int - var comments []*github.IssueComment - for _, c := range q.Repository.Discussion.Comments.Nodes { - comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))}) + if includeReplies { + var q struct { + Repository struct { + Discussion struct { + Comments struct { + Nodes []struct { + ID githubv4.ID + Body githubv4.String + IsAnswer githubv4.Boolean + Replies struct { + Nodes []struct { + ID githubv4.ID + Body githubv4.String + IsAnswer githubv4.Boolean + } + TotalCount int + } `graphql:"replies(first: 100)"` + } + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int + } `graphql:"comments(first: $first, after: $after)"` + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + if err := client.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + for _, c := range q.Repository.Discussion.Comments.Nodes { + comment := MinimalDiscussionComment{ + ID: fmt.Sprintf("%v", c.ID), + Body: string(c.Body), + IsAnswer: bool(c.IsAnswer), + ReplyTotalCount: c.Replies.TotalCount, + } + for _, r := range c.Replies.Nodes { + comment.Replies = append(comment.Replies, MinimalDiscussionComment{ + ID: fmt.Sprintf("%v", r.ID), + Body: string(r.Body), + IsAnswer: bool(r.IsAnswer), + }) + } + comments = append(comments, comment) + } + pageInfo = q.Repository.Discussion.Comments.PageInfo + totalCount = q.Repository.Discussion.Comments.TotalCount + } else { + var q struct { + Repository struct { + Discussion struct { + Comments struct { + Nodes []struct { + ID githubv4.ID + Body githubv4.String + IsAnswer githubv4.Boolean + } + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int + } `graphql:"comments(first: $first, after: $after)"` + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + if err := client.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + for _, c := range q.Repository.Discussion.Comments.Nodes { + comments = append(comments, MinimalDiscussionComment{ + ID: fmt.Sprintf("%v", c.ID), + Body: string(c.Body), + IsAnswer: bool(c.IsAnswer), + }) + } + pageInfo = q.Repository.Discussion.Comments.PageInfo + totalCount = q.Repository.Discussion.Comments.TotalCount } // Create response with pagination info - response := map[string]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) @@ -507,6 +585,409 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve ) } +func DiscussionCommentWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataDiscussions, + mcp.Tool{ + Name: "discussion_comment_write", + Description: t("TOOL_DISCUSSION_COMMENT_WRITE_DESCRIPTION", `Write operations for discussion comments. +Supports adding top-level comments, replying to existing comments, updating comment content, deleting comments, and marking or unmarking comments as the answer.`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_DISCUSSION_COMMENT_WRITE_USER_TITLE", "Manage discussion comments"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: `Write operation to perform on a discussion comment. +Options are: +- 'add' - adds a new top-level comment to a discussion. +- 'reply' - replies to a top-level discussion comment (GitHub Discussions only support one level of nesting). +- 'update' - updates an existing discussion comment. +- 'delete' - deletes a discussion comment. +- 'mark_answer' - marks a discussion comment as the answer (Q&A only). +- 'unmark_answer' - unmarks a discussion comment as the answer (Q&A only). +`, + Enum: []any{"add", "reply", "update", "delete", "mark_answer", "unmark_answer"}, + }, + "owner": { + Type: "string", + Description: "Repository owner (required for 'add' and 'reply' methods)", + }, + "repo": { + Type: "string", + Description: "Repository name (required for 'add' and 'reply' methods)", + }, + "discussionNumber": { + Type: "number", + Description: "Discussion number (required for 'add' and 'reply' methods)", + }, + "body": { + Type: "string", + Description: "Comment content (required for 'add', 'reply', and 'update' methods)", + }, + "commentNodeID": { + Type: "string", + Description: "The Node ID of the discussion comment (required for 'reply', 'update', 'delete', 'mark_answer', and 'unmark_answer' methods). For 'reply', this is the top-level comment to reply to; GitHub Discussions only support one level of nesting.", + }, + }, + Required: []string{"method"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil + } + + switch method { + case "add": + return addDiscussionComment(ctx, client, args) + case "reply": + return replyToDiscussionComment(ctx, client, args) + case "update": + return updateDiscussionComment(ctx, client, args) + case "delete": + return deleteDiscussionComment(ctx, client, args) + case "mark_answer": + return markDiscussionCommentAsAnswer(ctx, client, args) + case "unmark_answer": + return unmarkDiscussionCommentAsAnswer(ctx, client, args) + default: + return utils.NewToolResultError("invalid method, must be one of: 'add', 'reply', 'update', 'delete', 'mark_answer', 'unmark_answer'"), nil, nil + } + }) +} + +func addDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + discussionNumber, err := RequiredInt(args, "discussionNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, err := RequiredParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Get the discussion's node ID using its number + var q struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "discussionNumber": githubv4.Int(discussionNumber), // #nosec G115 - discussion numbers are always small positive integers + } + if err := client.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.AddDiscussionCommentInput{ + DiscussionID: q.Repository.Discussion.ID, + Body: githubv4.String(body), + } + + var mutation struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + comment := mutation.AddDiscussionComment.Comment + out, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", comment.ID), + URL: string(comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func requiredCommentNodeID(args map[string]any) (string, error) { + commentNodeID, err := RequiredParam[string](args, "commentNodeID") + if err != nil { + return "", err + } + if strings.TrimSpace(commentNodeID) == "" { + return "", fmt.Errorf("commentNodeID cannot be blank") + } + return commentNodeID, nil +} + +func replyToDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + discussionNumber, err := RequiredInt(args, "discussionNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, err := RequiredParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // The GitHub API silently ignores an invalid ReplyToID and creates a top-level + // comment instead of returning an error, so we validate upfront that the node + // exists and is a DiscussionComment to give callers a clear failure. + var nodeQuery struct { + Node struct { + DiscussionComment struct { + ID *githubv4.ID + Discussion struct { + ID githubv4.ID + } `graphql:"discussion"` + } `graphql:"... on DiscussionComment"` + } `graphql:"node(id: $replyToID)"` + } + if err := client.Query(ctx, &nodeQuery, map[string]any{ + "replyToID": githubv4.ID(commentNodeID), + }); err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to validate commentNodeID: %v", err)), nil, nil + } + if nodeQuery.Node.DiscussionComment.ID == nil || *nodeQuery.Node.DiscussionComment.ID == "" { + return utils.NewToolResultError(fmt.Sprintf("commentNodeID %q does not resolve to a valid discussion comment", commentNodeID)), nil, nil + } + + // Get the discussion's node ID using its number + var q struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "discussionNumber": githubv4.Int(discussionNumber), // #nosec G115 - discussion numbers are always small positive integers + } + if err := client.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if nodeQuery.Node.DiscussionComment.Discussion.ID != q.Repository.Discussion.ID { + return utils.NewToolResultError( + fmt.Sprintf("commentNodeID %q does not belong to discussion #%d in %s/%s", commentNodeID, discussionNumber, owner, repo), + ), nil, nil + } + + replyToID := githubv4.ID(commentNodeID) + input := githubv4.AddDiscussionCommentInput{ + DiscussionID: nodeQuery.Node.DiscussionComment.Discussion.ID, + Body: githubv4.String(body), + ReplyToID: &replyToID, + } + + var mutation struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + comment := mutation.AddDiscussionComment.Comment + out, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", comment.ID), + URL: string(comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func updateDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, err := RequiredParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.UpdateDiscussionCommentInput{ + CommentID: githubv4.ID(commentNodeID), + Body: githubv4.String(body), + } + + var mutation struct { + UpdateDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + comment := mutation.UpdateDiscussionComment.Comment + out, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", comment.ID), + URL: string(comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func deleteDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.DeleteDiscussionCommentInput{ + ID: githubv4.ID(commentNodeID), + } + + var mutation struct { + DeleteDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"deleteDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + comment := mutation.DeleteDiscussionComment.Comment + out, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", comment.ID), + URL: string(comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func markDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.MarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID(commentNodeID), + } + var mutation struct { + MarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"markDiscussionCommentAsAnswer(input: $input)"` + } + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + out, err := json.Marshal(struct { + DiscussionID string `json:"discussionID"` + DiscussionURL string `json:"discussionURL"` + }{ + DiscussionID: fmt.Sprintf("%v", mutation.MarkDiscussionCommentAsAnswer.Discussion.ID), + DiscussionURL: string(mutation.MarkDiscussionCommentAsAnswer.Discussion.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func unmarkDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.UnmarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID(commentNodeID), + } + var mutation struct { + UnmarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"unmarkDiscussionCommentAsAnswer(input: $input)"` + } + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + out, err := json.Marshal(struct { + DiscussionID string `json:"discussionID"` + DiscussionURL string `json:"discussionURL"` + }{ + DiscussionID: fmt.Sprintf("%v", mutation.UnmarkDiscussionCommentAsAnswer.Discussion.ID), + DiscussionURL: string(mutation.UnmarkDiscussionCommentAsAnswer.Discussion.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataDiscussions, @@ -570,7 +1051,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 +1069,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), 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 8d12b78c2a..0000000000 --- a/pkg/github/dynamic_tools_test.go +++ /dev/null @@ -1,231 +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 := NewInventory(translations.NullTranslationHelper). - WithToolsets([]string{}). - Build() - - // 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 := NewInventory(translations.NullTranslationHelper). - WithToolsets([]string{}). - Build() - - // 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 := NewInventory(translations.NullTranslationHelper). - WithToolsets([]string{}). - Build() - - // 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), - 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 := NewInventory(translations.NullTranslationHelper). - WithToolsets([]string{}). - Build() - - // 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 := NewInventory(translations.NullTranslationHelper).Build() - - // 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 047042e44e..0f77f6c872 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -1,6 +1,76 @@ package github +import "slices" + +// MCPAppsFeatureFlag is the feature flag name for MCP Apps (interactive UI forms). +const MCPAppsFeatureFlag = "remote_mcp_ui_apps" + +// FeatureFlagCSVOutput is the feature flag name for CSV output on list tools. +const FeatureFlagCSVOutput = "csv_output" + +// FeatureFlagIFCLabels is the feature flag name for IFC security labels in tool results. +const FeatureFlagIFCLabels = "ifc_labels" + +// FeatureFlagIssueFields is the feature flag name for Issues 2.0 custom field +// support: the list_issue_fields tool, the field_filters input on list_issues, +// and field_values enrichment in list_issues / search_issues output. +const FeatureFlagIssueFields = "remote_mcp_issue_fields" + +// AllowedFeatureFlags is the allowlist of feature flags that can be enabled +// by users via --features CLI flag or X-MCP-Features HTTP header. +// Only flags in this list are accepted; unknown flags are silently ignored. +// This is the single source of truth for which flags are user-controllable. +var AllowedFeatureFlags = []string{ + MCPAppsFeatureFlag, + FeatureFlagCSVOutput, + FeatureFlagIFCLabels, + FeatureFlagIssueFields, + FeatureFlagIssuesGranular, + FeatureFlagPullRequestsGranular, +} + +// InsidersFeatureFlags is the list of feature flags that insiders mode enables. +// When insiders mode is active, all flags in this list are treated as enabled. +// This is the single source of truth for what "insiders" means in terms of +// feature flag expansion. +var InsidersFeatureFlags = []string{ + MCPAppsFeatureFlag, + FeatureFlagCSVOutput, + FeatureFlagIFCLabels, + FeatureFlagIssueFields, +} + // FeatureFlags defines runtime feature toggles that adjust tool behavior. type FeatureFlags struct { LockdownMode bool } + +// 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 new file mode 100644 index 0000000000..3f9d211953 --- /dev/null +++ b/pkg/github/feature_flags_test.go @@ -0,0 +1,218 @@ +package github + +import ( + "context" + "encoding/json" + "testing" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/utils" +) + +// RemoteMCPEnthusiasticGreeting is a dummy test feature flag . +const RemoteMCPEnthusiasticGreeting = "remote_mcp_enthusiastic_greeting" + +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. +// This tool is for testing and demonstration purposes only. +func HelloWorldTool(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataContext, // Use existing "context" toolset + mcp.Tool{ + Name: "hello_world", + Description: t("TOOL_HELLO_WORLD_DESCRIPTION", "A simple greeting tool that demonstrates feature flag conditional behavior"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_HELLO_WORLD_TITLE", "Hello World"), + ReadOnlyHint: true, + }, + }, + []scopes.Scope{}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { + + // Check feature flag to determine greeting style + greeting := "Hello, world!" + if deps.IsFeatureEnabled(ctx, RemoteMCPEnthusiasticGreeting) { + greeting += " Welcome to the future of MCP! 🎉" + } + + // Build response + response := map[string]any{ + "greeting": greeting, + } + + jsonBytes, err := json.Marshal(response) + if err != nil { + return utils.NewToolResultError("failed to marshal response"), nil, nil + } + + return utils.NewToolResultText(string(jsonBytes)), nil, nil + }, + ) +} + +func TestHelloWorld_ConditionalBehavior_Featureflag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + featureFlagEnabled bool + inputName string + expectedGreeting string + }{ + { + name: "Feature flag disabled - default greeting", + featureFlagEnabled: false, + expectedGreeting: "Hello, world!", + }, + { + name: "Feature flag enabled - enthusiastic greeting", + featureFlagEnabled: true, + expectedGreeting: "Hello, world! Welcome to the future of MCP! 🎉", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var enabledFlags []string + if tt.featureFlagEnabled { + enabledFlags = append(enabledFlags, RemoteMCPEnthusiasticGreeting) + } + + // Create deps with the checker + deps := NewBaseDeps( + nil, nil, nil, nil, + translations.NullTranslationHelper, + FeatureFlags{}, + 0, + featureCheckerFor(enabledFlags...), + stubExporters(), + ) + + // Get the tool and its handler + tool := HelloWorldTool(translations.NullTranslationHelper) + handler := tool.Handler(deps) + + // Call the handler with deps in context + ctx := ContextWithDeps(context.Background(), deps) + result, err := handler(ctx, &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Arguments: json.RawMessage(`{}`), + }, + }) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.Content, 1) + + // Parse the response - should be TextContent + textContent, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok, "expected content to be TextContent") + + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + // Verify the greeting matches expected based on feature flag + assert.Equal(t, tt.expectedGreeting, response["greeting"]) + }) + } +} + +func TestResolveFeatureFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + enabledFeatures []string + insidersMode bool + expectedFlags []string + unexpectedFlags []string + }{ + { + 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: "insiders mode enables internal-only flags", + enabledFeatures: nil, + insidersMode: true, + expectedFlags: []string{FeatureFlagIFCLabels}, + }, + { + name: "ifc_labels can be directly enabled", + enabledFeatures: []string{FeatureFlagIFCLabels}, + expectedFlags: []string{FeatureFlagIFCLabels}, + }, + { + name: "unknown flags are filtered out", + enabledFeatures: []string{"unknown_flag", "another_unknown"}, + unexpectedFlags: []string{"unknown_flag", "another_unknown"}, + }, + { + name: "mix of known and unknown flags", + enabledFeatures: []string{MCPAppsFeatureFlag, "unknown_flag"}, + expectedFlags: []string{MCPAppsFeatureFlag}, + unexpectedFlags: []string{"unknown_flag"}, + }, + { + name: "user-only flags can be enabled but are not turned on by insiders", + enabledFeatures: []string{FeatureFlagIssuesGranular}, + insidersMode: false, + expectedFlags: []string{FeatureFlagIssuesGranular}, + }, + { + name: "insiders does not enable user-only allowed flags", + enabledFeatures: nil, + insidersMode: true, + unexpectedFlags: []string{FeatureFlagIssuesGranular, FeatureFlagPullRequestsGranular}, + }, + { + name: "explicit plus insiders deduplicates", + enabledFeatures: []string{MCPAppsFeatureFlag}, + insidersMode: true, + expectedFlags: InsidersFeatureFlags, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + 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..de577af04d 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/gists_test.go b/pkg/github/gists_test.go index 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..515d8b65f8 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -11,7 +11,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/git_test.go b/pkg/github/git_test.go index 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..90b42b22c5 --- /dev/null +++ b/pkg/github/granular_tools_test.go @@ -0,0 +1,1481 @@ +package github + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + gogithub "github.com/google/go-github/v87/github" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func granularToolsForToolset(toolsetID inventory.ToolsetID, featureFlag string) []inventory.ServerTool { + var result []inventory.ServerTool + for _, tool := range AllTools(translations.NullTranslationHelper) { + if tool.Toolset.ID == toolsetID && tool.FeatureFlagEnable == featureFlag { + result = append(result, tool) + } + } + return result +} + +func TestGranularToolSnaps(t *testing.T) { + // Test toolsnaps for all granular tools + toolConstructors := []func(translations.TranslationHelperFunc) inventory.ServerTool{ + GranularCreateIssue, + GranularUpdateIssueTitle, + GranularUpdateIssueBody, + GranularUpdateIssueAssignees, + GranularUpdateIssueLabels, + GranularUpdateIssueMilestone, + GranularUpdateIssueType, + GranularUpdateIssueState, + GranularAddSubIssue, + GranularRemoveSubIssue, + GranularReprioritizeSubIssue, + GranularSetIssueFields, + GranularUpdatePullRequestTitle, + GranularUpdatePullRequestBody, + GranularUpdatePullRequestState, + GranularUpdatePullRequestDraftState, + GranularRequestPullRequestReviewers, + GranularCreatePullRequestReview, + GranularSubmitPendingPullRequestReview, + GranularDeletePendingPullRequestReview, + GranularAddPullRequestReviewComment, + GranularResolveReviewThread, + GranularUnresolveReviewThread, + } + + for _, constructor := range toolConstructors { + serverTool := constructor(translations.NullTranslationHelper) + t.Run(serverTool.Tool.Name, func(t *testing.T) { + require.NoError(t, toolsnaps.Test(serverTool.Tool.Name, serverTool.Tool)) + }) + } +} + +func TestIssuesGranularToolset(t *testing.T) { + t.Run("toolset contains expected granular tools", func(t *testing.T) { + tools := granularToolsForToolset(ToolsetMetadataIssues.ID, FeatureFlagIssuesGranular) + + toolNames := make([]string, 0, len(tools)) + for _, tool := range tools { + toolNames = append(toolNames, tool.Tool.Name) + } + + expected := []string{ + "create_issue", + "update_issue_title", + "update_issue_body", + "update_issue_assignees", + "update_issue_labels", + "update_issue_milestone", + "update_issue_type", + "update_issue_state", + "add_sub_issue", + "remove_sub_issue", + "reprioritize_sub_issue", + "set_issue_fields", + } + for _, name := range expected { + assert.Contains(t, toolNames, name) + } + assert.Len(t, tools, len(expected)) + }) + + t.Run("all granular tools have correct feature flag", func(t *testing.T) { + for _, tool := range granularToolsForToolset(ToolsetMetadataIssues.ID, FeatureFlagIssuesGranular) { + assert.Equal(t, FeatureFlagIssuesGranular, tool.FeatureFlagEnable, "tool %s", tool.Tool.Name) + } + }) +} + +func TestPullRequestsGranularToolset(t *testing.T) { + t.Run("toolset contains expected granular tools", func(t *testing.T) { + tools := granularToolsForToolset(ToolsetMetadataPullRequests.ID, FeatureFlagPullRequestsGranular) + + toolNames := make([]string, 0, len(tools)) + for _, tool := range tools { + toolNames = append(toolNames, tool.Tool.Name) + } + + expected := []string{ + "update_pull_request_title", + "update_pull_request_body", + "update_pull_request_state", + "update_pull_request_draft_state", + "request_pull_request_reviewers", + "create_pull_request_review", + "submit_pending_pull_request_review", + "delete_pending_pull_request_review", + "add_pull_request_review_comment", + "resolve_review_thread", + "unresolve_review_thread", + } + for _, name := range expected { + assert.Contains(t, toolNames, name) + } + assert.Len(t, tools, len(expected)) + }) + + t.Run("all granular tools have correct feature flag", func(t *testing.T) { + for _, tool := range granularToolsForToolset(ToolsetMetadataPullRequests.ID, FeatureFlagPullRequestsGranular) { + assert.Equal(t, FeatureFlagPullRequestsGranular, tool.FeatureFlagEnable, "tool %s", tool.Tool.Name) + } + }) +} + +// --- Issue granular tool handler tests --- + +func TestGranularCreateIssue(t *testing.T) { + mockIssue := &gogithub.Issue{ + Number: gogithub.Ptr(1), + Title: gogithub.Ptr("Test Issue"), + Body: gogithub.Ptr("Test body"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectedErrMsg string + }{ + { + name: "successful creation", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{ + "title": "Test Issue", + "body": "Test body", + }).andThen(mockResponse(t, http.StatusCreated, mockIssue)), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test Issue", + "body": "Test body", + }, + }, + { + name: "missing required parameter", + mockedClient: MockHTTPClientWithHandlers(nil), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectedErrMsg: "missing required parameter: title", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + serverTool := GranularCreateIssue(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectedErrMsg != "" { + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + assert.False(t, result.IsError) + }) + } +} + +func TestGranularUpdateIssueTitle(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, &gogithub.Issue{ + Number: gogithub.Ptr(42), + Title: gogithub.Ptr("New Title"), + }), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueTitle(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "title": "New Title", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestGranularUpdateIssueBody(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ + "body": "Updated body", + }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{ + Number: gogithub.Ptr(1), + Body: gogithub.Ptr("Updated body"), + })), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueBody(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "body": "Updated body", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestGranularUpdateIssueAssignees(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ + "assignees": []any{"user1", "user2"}, + }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueAssignees(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "assignees": []string{"user1", "user2"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) +} + +func TestGranularUpdateIssueLabels(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "labels as plain strings", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{"bug", "enhancement"}, + }, + expectedReq: map[string]any{ + "labels": []any{"bug", "enhancement"}, + }, + }, + { + name: "label objects without rationale serialize as strings", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "bug"}, + "enhancement", + }, + }, + expectedReq: map[string]any{ + "labels": []any{"bug", "enhancement"}, + }, + }, + { + name: "mixed strings and label objects with rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + "triage", + map[string]any{"name": "bug", "rationale": " Reports a crash when saving "}, + map[string]any{"name": "frontend", "rationale": "Mentions the UI button"}, + }, + }, + expectedReq: map[string]any{ + "labels": []any{ + "triage", + map[string]any{"name": "bug", "rationale": "Reports a crash when saving"}, + map[string]any{"name": "frontend", "rationale": "Mentions the UI button"}, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueLabels(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + +func TestGranularUpdateIssueLabelsSuggest(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "single label suggested without rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "bug", "is_suggestion": true}, + }, + }, + expectedReq: map[string]any{ + "labels": []any{ + map[string]any{"name": "bug", "suggest": true}, + }, + }, + }, + { + name: "suggested label with rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "frontend", "rationale": "Mentions the UI button", "is_suggestion": true}, + }, + }, + expectedReq: map[string]any{ + "labels": []any{ + map[string]any{"name": "frontend", "rationale": "Mentions the UI button", "suggest": true}, + }, + }, + }, + { + name: "mix of plain, applied-with-rationale, and suggested labels", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + "triage", + map[string]any{"name": "bug", "rationale": "Reports a crash when saving"}, + map[string]any{"name": "needs-design", "is_suggestion": true}, + }, + }, + expectedReq: map[string]any{ + "labels": []any{ + "triage", + map[string]any{"name": "bug", "rationale": "Reports a crash when saving"}, + map[string]any{"name": "needs-design", "suggest": true}, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueLabels(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + +func TestGranularUpdateIssueLabelsInvalidRationale(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedErrText string + }{ + { + name: "rationale too long", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "bug", "rationale": strings.Repeat("a", 281)}, + }, + }, + expectedErrText: "label rationale must be 280 characters or less", + }, + { + name: "label object missing name", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"rationale": "no name provided"}, + }, + }, + expectedErrText: "each label object must have a 'name' string", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))} + serverTool := GranularUpdateIssueLabels(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrText) + }) + } +} + +func 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 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: 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", "user2"}, + }) + 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 suggest flag", func(t *testing.T) { + suggestTrue := githubv4.Boolean(true) + matchers := []githubv4mock.Matcher{ + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(5), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{"id": "ISSUE_123"}, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + SetIssueFieldValue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + IssueFieldValues []struct { + TextValue struct { + Value string + } `graphql:"... on IssueFieldTextValue"` + SingleSelectValue struct { + Name string + } `graphql:"... on IssueFieldSingleSelectValue"` + DateValue struct { + Value string + } `graphql:"... on IssueFieldDateValue"` + NumberValue struct { + Value float64 + } `graphql:"... on IssueFieldNumberValue"` + } + } `graphql:"setIssueFieldValue(input: $input)"` + }{}, + SetIssueFieldValueInput{ + IssueID: githubv4.ID("ISSUE_123"), + IssueFields: []IssueFieldCreateOrUpdateInput{ + { + FieldID: githubv4.ID("FIELD_1"), + TextValue: githubv4.NewString(githubv4.String("hello")), + Rationale: githubv4.NewString(githubv4.String("Reflects the reported severity")), + Suggest: &suggestTrue, + }, + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "setIssueFieldValue": map[string]any{ + "issue": map[string]any{ + "id": "ISSUE_123", + "number": 5, + "url": "https://github.com/owner/repo/issues/5", + }, + }, + }), + ), + } + + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...)) + deps := BaseDeps{GQLClient: gqlClient} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{ + "field_id": "FIELD_1", + "text_value": "hello", + "rationale": "Reflects the reported severity", + "is_suggestion": true, + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) +} diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 0bb73008ec..fdac78ce3f 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" @@ -38,6 +40,7 @@ const ( GetReposSubscriptionByOwnerByRepo = "GET /repos/{owner}/{repo}/subscription" PutReposSubscriptionByOwnerByRepo = "PUT /repos/{owner}/{repo}/subscription" DeleteReposSubscriptionByOwnerByRepo = "DELETE /repos/{owner}/{repo}/subscription" + ListCollaborators = "GET /repos/{owner}/{repo}/collaborators" // Git endpoints GetReposGitTreesByOwnerByRepoByTree = "GET /repos/{owner}/{repo}/git/trees/{tree}" @@ -50,6 +53,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}" @@ -72,6 +76,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" @@ -135,6 +140,7 @@ const ( GetSearchIssues = "GET /search/issues" GetSearchUsers = "GET /search/users" GetSearchRepositories = "GET /search/repositories" + GetSearchCommits = "GET /search/commits" // Raw content endpoints (used for GitHub raw content API, not standard API) // These are used with the raw content client that interacts with raw.githubusercontent.com @@ -175,6 +181,22 @@ type expectations struct { requestBody any } +// mustNewGHClient creates a new GitHub client for testing. +// If httpClient is nil, a client with no options is created. +// The test fails immediately if client creation fails. +func mustNewGHClient(t *testing.T, httpClient *http.Client) *gogithub.Client { + t.Helper() + var client *gogithub.Client + var err error + if httpClient == nil { + client, err = gogithub.NewClient() + } else { + client, err = gogithub.NewClient(gogithub.WithHTTPClient(httpClient)) + } + require.NoError(t, err) + return client +} + // expect is a helper function to create a partial mock that expects various // request behaviors, such as path, query parameters, and request body. func expect(t *testing.T, e expectations) *partialMock { @@ -216,9 +238,15 @@ func expectRequestBody(t *testing.T, expectedRequestBody any) *partialMock { type partialMock struct { t *testing.T - expectedPath string - expectedQueryParams map[string]string - expectedRequestBody any + expectedPath string + expectedQueryParams map[string]string + expectedRequestBody any + expectedHeaderContains map[string]string +} + +func (p *partialMock) withHeaders(headers map[string]string) *partialMock { + p.expectedHeaderContains = headers + return p } func (p *partialMock) andThen(responseHandler http.HandlerFunc) http.HandlerFunc { @@ -243,13 +271,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 +304,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 +323,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 +398,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 +415,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 +423,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 +431,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 +440,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 +449,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 +617,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/instructions_test.go b/pkg/github/instructions_test.go deleted file mode 100644 index b8ad2ba8c5..0000000000 --- a/pkg/github/instructions_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package github - -import ( - "os" - "strings" - "testing" -) - -func TestGenerateInstructions(t *testing.T) { - tests := []struct { - name string - enabledToolsets []string - expectedEmpty bool - }{ - { - name: "empty toolsets", - enabledToolsets: []string{}, - expectedEmpty: false, - }, - { - name: "only context toolset", - enabledToolsets: []string{"context"}, - expectedEmpty: false, - }, - { - name: "pull requests toolset", - enabledToolsets: []string{"pull_requests"}, - expectedEmpty: false, - }, - { - name: "issues toolset", - enabledToolsets: []string{"issues"}, - expectedEmpty: false, - }, - { - name: "discussions toolset", - enabledToolsets: []string{"discussions"}, - expectedEmpty: false, - }, - { - name: "multiple toolsets (context + pull_requests)", - enabledToolsets: []string{"context", "pull_requests"}, - expectedEmpty: false, - }, - { - name: "multiple toolsets (issues + pull_requests)", - enabledToolsets: []string{"issues", "pull_requests"}, - expectedEmpty: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := GenerateInstructions(tt.enabledToolsets) - - if tt.expectedEmpty { - if result != "" { - t.Errorf("Expected empty instructions but got: %s", result) - } - } else { - if result == "" { - t.Errorf("Expected non-empty instructions but got empty result") - } - } - }) - } -} - -func TestGenerateInstructionsWithDisableFlag(t *testing.T) { - tests := []struct { - name string - disableEnvValue string - enabledToolsets []string - expectedEmpty bool - }{ - { - name: "DISABLE_INSTRUCTIONS=true returns empty", - disableEnvValue: "true", - enabledToolsets: []string{"context", "issues", "pull_requests"}, - expectedEmpty: true, - }, - { - name: "DISABLE_INSTRUCTIONS=false returns normal instructions", - disableEnvValue: "false", - enabledToolsets: []string{"context"}, - expectedEmpty: false, - }, - { - name: "DISABLE_INSTRUCTIONS unset returns normal instructions", - disableEnvValue: "", - enabledToolsets: []string{"issues"}, - expectedEmpty: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Save original env value - originalValue := os.Getenv("DISABLE_INSTRUCTIONS") - defer func() { - if originalValue == "" { - os.Unsetenv("DISABLE_INSTRUCTIONS") - } else { - os.Setenv("DISABLE_INSTRUCTIONS", originalValue) - } - }() - - // Set test env value - if tt.disableEnvValue == "" { - os.Unsetenv("DISABLE_INSTRUCTIONS") - } else { - os.Setenv("DISABLE_INSTRUCTIONS", tt.disableEnvValue) - } - - result := GenerateInstructions(tt.enabledToolsets) - - if tt.expectedEmpty { - if result != "" { - t.Errorf("Expected empty instructions but got: %s", result) - } - } else { - if result == "" { - t.Errorf("Expected non-empty instructions but got empty result") - } - } - }) - } -} - -func TestGetToolsetInstructions(t *testing.T) { - tests := []struct { - toolset string - expectedEmpty bool - enabledToolsets []string - expectedToContain string - notExpectedToContain string - }{ - { - toolset: "pull_requests", - expectedEmpty: false, - enabledToolsets: []string{"pull_requests", "repos"}, - expectedToContain: "pull_request_template.md", - }, - { - toolset: "pull_requests", - expectedEmpty: false, - enabledToolsets: []string{"pull_requests"}, - notExpectedToContain: "pull_request_template.md", - }, - { - toolset: "issues", - expectedEmpty: false, - }, - { - toolset: "discussions", - expectedEmpty: false, - }, - { - toolset: "nonexistent", - expectedEmpty: true, - }, - } - - for _, tt := range tests { - t.Run(tt.toolset, func(t *testing.T) { - result := getToolsetInstructions(tt.toolset, tt.enabledToolsets) - if tt.expectedEmpty { - if result != "" { - t.Errorf("Expected empty result for toolset '%s', but got: %s", tt.toolset, result) - } - } else { - if result == "" { - t.Errorf("Expected non-empty result for toolset '%s', but got empty", tt.toolset) - } - } - - if tt.expectedToContain != "" && !strings.Contains(result, tt.expectedToContain) { - t.Errorf("Expected result to contain '%s' for toolset '%s', but it did not. Result: %s", tt.expectedToContain, tt.toolset, result) - } - - if tt.notExpectedToContain != "" && strings.Contains(result, tt.notExpectedToContain) { - t.Errorf("Did not expect result to contain '%s' for toolset '%s', but it did. Result: %s", tt.notExpectedToContain, tt.toolset, result) - } - }) - } -} diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go new file mode 100644 index 0000000000..1eabbc02f3 --- /dev/null +++ b/pkg/github/issue_fields.go @@ -0,0 +1,264 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +// IssueField represents a repository issue field definition. +type IssueField struct { + ID string `json:"id"` + DatabaseID int64 `json:"full_database_id,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DataType string `json:"data_type"` + Visibility string `json:"visibility"` + Options []IssueSingleSelectFieldOption `json:"options,omitempty"` +} + +// IssueSingleSelectFieldOption represents an option for a single_select issue field. +type IssueSingleSelectFieldOption struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Color string `json:"color"` + Priority *int `json:"priority,omitempty"` +} + +// issueFieldNode is the GraphQL fragment for a single issue field in the IssueFields union. +// Only the fragment matching __typename is populated; read from the matching fragment. +// fullDatabaseId (BigInt scalar, returned as string) is fetched on each concrete type because +// shurcooL/githubv4 does not support interface fragments at the top level of a union. +type issueFieldNode struct { + TypeName githubv4.String `graphql:"__typename"` + IssueFieldText struct { + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + } `graphql:"... on IssueFieldText"` + IssueFieldNumber struct { + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + } `graphql:"... on IssueFieldNumber"` + IssueFieldDate struct { + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + } `graphql:"... on IssueFieldDate"` + IssueFieldSingleSelect struct { + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + Options []struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + Color githubv4.String + Priority *int + } + } `graphql:"... on IssueFieldSingleSelect"` +} + +// issueFieldsRepoQuery is the GraphQL query for listing issue fields on a repository. +type issueFieldsRepoQuery struct { + Repository struct { + IssueFields struct { + Nodes []issueFieldNode + } `graphql:"issueFields(first: 100)"` + } `graphql:"repository(owner: $owner, name: $name)"` +} + +// issueFieldsOrgQuery is the GraphQL query for listing issue fields on an organization. +type issueFieldsOrgQuery struct { + Organization struct { + IssueFields struct { + Nodes []issueFieldNode + } `graphql:"issueFields(first: 100)"` + } `graphql:"organization(login: $login)"` +} + +// ListIssueFields creates a tool to list issue field definitions for a repository or organization. +// Gated by FeatureFlagIssueFields: the tool is only registered when the flag is on. +func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "list_issue_fields", + Description: t("TOOL_LIST_ISSUE_FIELDS_DESCRIPTION", "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_ISSUE_FIELDS_USER_TITLE", "List issue fields"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The account owner of the repository or organization. The name is not case sensitive.", + }, + "repo": { + Type: "string", + Description: "The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly.", + }, + }, + Required: []string{"owner"}, + }, + }, + []scopes.Scope{scopes.Repo, scopes.ReadOrg}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := OptionalParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + + fields, err := fetchIssueFields(ctx, gqlClient, owner, repo) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil + } + + r, err := json.Marshal(fields) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil + }) + st.FeatureFlagEnable = FeatureFlagIssueFields + return st +} + +// fetchIssueFields returns the issue field definitions for the given owner. +// If repo is provided, fields are scoped to that repository (inherited from its +// organization); otherwise fields are returned directly from the organization. +func fetchIssueFields(ctx context.Context, gqlClient *githubv4.Client, owner, repo string) ([]IssueField, error) { + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") + if repo != "" { + var query issueFieldsRepoQuery + vars := map[string]any{ + "owner": githubv4.String(owner), + "name": githubv4.String(repo), + } + if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { + return nil, err + } + return issueFieldsFromNodes(query.Repository.IssueFields.Nodes), nil + } + + var query issueFieldsOrgQuery + vars := map[string]any{ + "login": githubv4.String(owner), + } + if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { + return nil, err + } + return issueFieldsFromNodes(query.Organization.IssueFields.Nodes), nil +} + +// issueFieldsFromNodes converts GraphQL issue field union nodes into IssueField values. +// Read from the fragment matching __typename; the other fragments are zero-valued. +func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField { + fields := make([]IssueField, 0, len(nodes)) + for _, node := range nodes { + var f IssueField + switch string(node.TypeName) { + case "IssueFieldSingleSelect": + opts := make([]IssueSingleSelectFieldOption, 0, len(node.IssueFieldSingleSelect.Options)) + for _, o := range node.IssueFieldSingleSelect.Options { + opts = append(opts, IssueSingleSelectFieldOption{ + ID: fmt.Sprintf("%v", o.ID), + Name: string(o.Name), + Description: string(o.Description), + Color: string(o.Color), + Priority: o.Priority, + }) + } + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldSingleSelect.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldSingleSelect.FullDatabaseID)), + Name: string(node.IssueFieldSingleSelect.Name), + Description: string(node.IssueFieldSingleSelect.Description), + DataType: string(node.IssueFieldSingleSelect.DataType), + Visibility: string(node.IssueFieldSingleSelect.Visibility), + Options: opts, + } + case "IssueFieldText": + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldText.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldText.FullDatabaseID)), + Name: string(node.IssueFieldText.Name), + Description: string(node.IssueFieldText.Description), + DataType: string(node.IssueFieldText.DataType), + Visibility: string(node.IssueFieldText.Visibility), + } + case "IssueFieldNumber": + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldNumber.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldNumber.FullDatabaseID)), + Name: string(node.IssueFieldNumber.Name), + Description: string(node.IssueFieldNumber.Description), + DataType: string(node.IssueFieldNumber.DataType), + Visibility: string(node.IssueFieldNumber.Visibility), + } + case "IssueFieldDate": + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldDate.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldDate.FullDatabaseID)), + Name: string(node.IssueFieldDate.Name), + Description: string(node.IssueFieldDate.Description), + DataType: string(node.IssueFieldDate.DataType), + Visibility: string(node.IssueFieldDate.Visibility), + } + default: + continue + } + fields = append(fields, f) + } + return fields +} + +// parseFullDatabaseID converts a BigInt scalar string (e.g. "12345") to int64. +// Returns 0 if the string is empty or cannot be parsed. +func parseFullDatabaseID(s string) int64 { + if s == "" { + return 0 + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0 + } + return n +} diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go new file mode 100644 index 0000000000..2c2b26ee2a --- /dev/null +++ b/pkg/github/issue_fields_test.go @@ -0,0 +1,308 @@ +package github + +import ( + "context" + "encoding/json" + "testing" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/jsonschema-go/jsonschema" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ListIssueFields(t *testing.T) { + // Verify tool definition + serverTool := ListIssueFields(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_issue_fields", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.True(t, tool.Annotations.ReadOnlyHint) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner"}) + assert.ElementsMatch(t, serverTool.RequiredScopes, []string{"repo", "read:org"}) + assert.ElementsMatch(t, serverTool.AcceptedScopes, []string{"repo", "read:org", "write:org", "admin:org"}) + + queryStruct := issueFieldsRepoQuery{} + defaultVars := map[string]any{ + "owner": githubv4.String("testowner"), + "name": githubv4.String("testrepo"), + } + orgQueryStruct := issueFieldsOrgQuery{} + defaultOrgVars := map[string]any{ + "login": githubv4.String("testowner"), + } + + tests := []struct { + name string + requestArgs map[string]any + mockQueryStruct any + mockVars map[string]any + gqlResponse githubv4mock.GQLResponse + expectError bool + expectedFields []IssueField + expectedErrMsg string + }{ + { + name: "no fields returns empty list", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{}, + }, + }, + }), + expectedFields: []IssueField{}, + }, + { + name: "text field returned", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldText", + "id": "IFT_1", + "fullDatabaseId": "42", + "name": "DRI", + "description": "Directly responsible individual", + "dataType": "TEXT", + "visibility": "ORG_ONLY", + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + { + ID: "IFT_1", + DatabaseID: 42, + Name: "DRI", + Description: "Directly responsible individual", + DataType: "TEXT", + Visibility: "ORG_ONLY", + }, + }, + }, + { + name: "single_select field with options returned", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "id": "IFSS_1", + "fullDatabaseId": "99", + "name": "Priority", + "description": "Level of importance", + "dataType": "SINGLE_SELECT", + "visibility": "ALL", + "options": []any{ + map[string]any{ + "id": "OPT_1", + "name": "High", + "color": "red", + }, + map[string]any{ + "id": "OPT_2", + "name": "Low", + "color": "blue", + }, + }, + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + { + ID: "IFSS_1", + DatabaseID: 99, + Name: "Priority", + Description: "Level of importance", + DataType: "SINGLE_SELECT", + Visibility: "ALL", + Options: []IssueSingleSelectFieldOption{ + {ID: "OPT_1", Name: "High", Color: "red"}, + {ID: "OPT_2", Name: "Low", Color: "blue"}, + }, + }, + }, + }, + { + name: "missing owner parameter", + requestArgs: map[string]any{ + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{}), + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "no repo returns org-level fields", + requestArgs: map[string]any{ + "owner": "testowner", + }, + mockQueryStruct: orgQueryStruct, + mockVars: defaultOrgVars, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldText", + "id": "IFT_1", + "fullDatabaseId": "77", + "name": "DRI", + "dataType": "TEXT", + "visibility": "ORG_ONLY", + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + {ID: "IFT_1", DatabaseID: 77, Name: "DRI", DataType: "TEXT", Visibility: "ORG_ONLY"}, + }, + }, + { + name: "number field returned", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldNumber", + "id": "IFN_1", + "fullDatabaseId": "101", + "name": "Engineering Staffing", + "dataType": "NUMBER", + "visibility": "ORG_ONLY", + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + {ID: "IFN_1", DatabaseID: 101, Name: "Engineering Staffing", DataType: "NUMBER", Visibility: "ORG_ONLY"}, + }, + }, + { + name: "date field returned", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldDate", + "id": "IFD_1", + "fullDatabaseId": "202", + "name": "Target Date", + "dataType": "DATE", + "visibility": "ORG_ONLY", + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + {ID: "IFD_1", DatabaseID: 202, Name: "Target Date", DataType: "DATE", Visibility: "ORG_ONLY"}, + }, + }, + { + name: "graphql error returns failure", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.ErrorResponse("boom"), + expectError: true, + expectedErrMsg: "failed to list issue fields", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + qs := tc.mockQueryStruct + if qs == nil { + qs = queryStruct + } + vars := tc.mockVars + if vars == nil { + vars = defaultVars + } + mockedHTTPClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher(qs, vars, tc.gqlResponse), + ) + gqlClient := githubv4.NewClient(mockedHTTPClient) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + require.NotNil(t, result) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError) + textContent := getTextResult(t, result) + + var returnedFields []IssueField + err = json.Unmarshal([]byte(textContent.Text), &returnedFields) + require.NoError(t, err) + require.Equal(t, len(tc.expectedFields), len(returnedFields)) + for i, expected := range tc.expectedFields { + assert.Equal(t, expected.ID, returnedFields[i].ID) + assert.Equal(t, expected.DatabaseID, returnedFields[i].DatabaseID) + assert.Equal(t, expected.Name, returnedFields[i].Name) + assert.Equal(t, expected.DataType, returnedFields[i].DataType) + assert.Equal(t, expected.Visibility, returnedFields[i].Visibility) + if expected.Options != nil { + require.Equal(t, len(expected.Options), len(returnedFields[i].Options)) + for j, opt := range expected.Options { + assert.Equal(t, opt.Name, returnedFields[i].Options[j].Name) + assert.Equal(t, opt.Color, returnedFields[i].Options[j].Color) + } + } + } + }) + } +} diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 1e29a0eef7..0469789812 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"` +} - 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 LegacyIssueQueryFragment struct { + Nodes []LegacyIssueFragment `graphql:"nodes"` + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int +} + +type LegacyIssueQueryResult interface { + GetLegacyIssueFragment() LegacyIssueQueryFragment + GetIsPrivate() bool +} + +type LegacyListIssuesQuery struct { + Repository struct { + Issues LegacyIssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + IsPrivate githubv4.Boolean + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +type LegacyListIssuesQueryTypeWithLabels struct { + Repository struct { + Issues LegacyIssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + IsPrivate githubv4.Boolean + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +type LegacyListIssuesQueryWithSince struct { + Repository struct { + Issues LegacyIssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + IsPrivate githubv4.Boolean + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +type LegacyListIssuesQueryTypeWithLabelsWithSince struct { + Repository struct { + Issues LegacyIssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + IsPrivate githubv4.Boolean + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +func (q *LegacyListIssuesQuery) GetLegacyIssueFragment() LegacyIssueQueryFragment { + return q.Repository.Issues +} +func (q *LegacyListIssuesQuery) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } + +func (q *LegacyListIssuesQueryTypeWithLabels) GetLegacyIssueFragment() LegacyIssueQueryFragment { + return q.Repository.Issues +} +func (q *LegacyListIssuesQueryTypeWithLabels) GetIsPrivate() bool { + return bool(q.Repository.IsPrivate) +} + +func (q *LegacyListIssuesQueryWithSince) GetLegacyIssueFragment() LegacyIssueQueryFragment { + return q.Repository.Issues +} +func (q *LegacyListIssuesQueryWithSince) GetIsPrivate() bool { + return bool(q.Repository.IsPrivate) +} + +func (q *LegacyListIssuesQueryTypeWithLabelsWithSince) GetLegacyIssueFragment() LegacyIssueQueryFragment { + return q.Repository.Issues +} +func (q *LegacyListIssuesQueryTypeWithLabelsWithSince) GetIsPrivate() bool { + return bool(q.Repository.IsPrivate) +} + +func getLegacyIssueQueryType(hasLabels bool, hasSince bool) any { + switch { + case hasLabels && hasSince: + return &LegacyListIssuesQueryTypeWithLabelsWithSince{} + case hasLabels: + return &LegacyListIssuesQueryTypeWithLabels{} + case hasSince: + return &LegacyListIssuesQueryWithSince{} + default: + return &LegacyListIssuesQuery{} } } @@ -310,26 +801,50 @@ Options are: return utils.NewToolResultErrorFromErr("failed to get GitHub graphql client", err), nil, nil } + // attachIFC adds the IFC label to a successful tool result when + // IFC labels are enabled. If the visibility lookup fails the + // label is omitted rather than misclassifying the result. + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + return r + } + isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo) + if err != nil { + return r + } + if r.Meta == nil { + r.Meta = mcp.Meta{} + } + r.Meta["ifc"] = ifc.LabelListIssues(isPrivate) + return r + } + switch method { case "get": - result, err := GetIssue(ctx, client, deps.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 +885,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 +956,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,7 +1078,6 @@ func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, } return utils.NewToolResultText(string(out)), nil - } // ListIssueTypes creates a tool to list defined issue types for an organization. This can be used to understand supported issue type values for creating or updating issues. @@ -676,7 +1211,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 +1227,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 +1337,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 +1372,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,58 +1511,316 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues") + var options []searchOption + if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + options = append(options, withSearchPostProcess(searchIssuesIFCPostProcess(deps))) + } + result, err := searchIssuesHandler(ctx, deps, args, options...) return result, nil, err }) } -// IssueWrite creates a tool to create a new or update an existing issue in a GitHub repository. -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": { - 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{ +// 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" + +// IssueWrite is the FeatureFlagIssueFields-enabled variant of issue_write +// (with the issue_fields parameter). LegacyIssueWrite is served when the flag +// is off. Both register under the tool name "issue_write"; exactly one is +// active at a time via mutually exclusive feature-flag annotations. When the +// flag is removed, delete LegacyIssueWrite outright and drop the feature-flag +// fields on IssueWrite. +func IssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "issue_write", + Description: t("TOOL_ISSUE_WRITE_DESCRIPTION", "Create a new or update an existing issue in a GitHub repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue"), + ReadOnlyHint: false, + }, + Meta: mcp.Meta{ + "ui": map[string]any{ + "resourceUri": IssueWriteUIResourceURI, + "visibility": []string{"model", "app"}, + }, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: `Write operation to perform on a single issue. +Options are: +- 'create' - creates a new issue. +- 'update' - updates an existing issue. +`, + Enum: []any{"create", "update"}, + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "Issue number to update", + }, + "title": { + Type: "string", + Description: "Issue title", + }, + "body": { + Type: "string", + Description: "Issue body content", + }, + "assignees": { + Type: "array", + Description: "Usernames to assign to this issue", + Items: &jsonschema.Schema{ Type: "string", }, }, @@ -1054,12 +1853,283 @@ Options are: Type: "number", Description: "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", }, + "issue_fields": { + Type: "array", + Description: "Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'.", + Items: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "field_name": { + Type: "string", + Description: "Issue field name (case-insensitive). Must match a field " + + "returned by list_issue_fields for this repository or its organization.", + }, + "value": { + Types: []string{"string", "number", "boolean"}, + Description: "Value to set. Use for text, number, and date fields " + + "(date as YYYY-MM-DD). For single-select fields, prefer " + + "'field_option_name' so the option is validated before the API " + + "call. Cannot be combined with 'field_option_name' or 'delete'.", + }, + "field_option_name": { + Type: "string", + Description: "Option name for single-select fields. Validated against " + + "the field's options before the API call. Cannot be combined with " + + "'value' or 'delete'.", + }, + "delete": { + Type: "boolean", + Enum: []any{true}, + Description: "Set to true to clear this field's current value on the " + + "issue. Cannot be combined with 'value' or 'field_option_name'.", + }, + }, + Required: []string{"field_name"}, + }, + }, }, Required: []string{"method", "owner", "repo"}, }, }, []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *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 + } + + 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, + // check if this is a UI form submission. The UI sends _ui_submitted=true + // to distinguish form submissions from LLM calls. + uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") + + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted { + if method == "update" { + // Skip the UI form when a state change is requested because + // the form only handles title/body editing and would lose the + // state transition (e.g. closing or reopening the issue). + if _, hasState := args["state"]; !hasState { + issueNumber, numErr := RequiredInt(args, "issue_number") + if numErr != nil { + return utils.NewToolResultError("issue_number is required for update method"), nil, nil + } + return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil + } + } else { + return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil + } + } + + 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 + } + + // Get labels + labels, err := OptionalStringArrayParam(args, "labels") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, 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) + return result, nil, err + default: + return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil + } + }) + st.FeatureFlagEnable = FeatureFlagIssueFields + st.FeatureFlagDisable = []string{FeatureFlagIssuesGranular} + return st +} + +// LegacyIssueWrite is the FeatureFlagIssueFields-disabled variant of issue_write. +// It is a near-verbatim copy of IssueWrite minus the issue_fields schema +// property, the issue_fields handler block, and the related GraphQL field +// resolution. Kept as a full duplicate so removing the FeatureFlagIssueFields +// flag is a single-function delete. Hidden whenever the granular toolset or +// the issue-fields flag is on. +func LegacyIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "issue_write", + Description: t("TOOL_ISSUE_WRITE_DESCRIPTION", "Create a new or update an existing issue in a GitHub repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue"), + ReadOnlyHint: false, + }, + Meta: mcp.Meta{ + "ui": map[string]any{ + "resourceUri": IssueWriteUIResourceURI, + "visibility": []string{"model", "app"}, + }, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: `Write operation to perform on a single issue. +Options are: +- 'create' - creates a new issue. +- 'update' - updates an existing issue. +`, + Enum: []any{"create", "update"}, + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "Issue number to update", + }, + "title": { + Type: "string", + Description: "Issue title", + }, + "body": { + Type: "string", + Description: "Issue body content", + }, + "assignees": { + Type: "array", + Description: "Usernames to assign to this issue", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "labels": { + Type: "array", + Description: "Labels to apply to this issue", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "milestone": { + Type: "number", + Description: "Milestone number", + }, + "type": { + Type: "string", + Description: "Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter.", + }, + "state": { + Type: "string", + Description: "New state", + Enum: []any{"open", "closed"}, + }, + "state_reason": { + Type: "string", + Description: "Reason for the state change. Ignored unless state is changed.", + Enum: []any{"completed", "not_planned", "duplicate"}, + }, + "duplicate_of": { + Type: "number", + Description: "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", + }, + }, + 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 @@ -1073,6 +2143,29 @@ Options are: if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + + // When MCP Apps are enabled and the client supports UI, + // check if this is a UI form submission. The UI sends _ui_submitted=true + // to distinguish form submissions from LLM calls. + uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") + + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted { + if method == "update" { + // Skip the UI form when a state change is requested because + // the form only handles title/body editing and would lose the + // state transition (e.g. closing or reopening the issue). + if _, hasState := args["state"]; !hasState { + issueNumber, numErr := RequiredInt(args, "issue_number") + if numErr != nil { + return utils.NewToolResultError("issue_number is required for update method"), nil, nil + } + return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil + } + } else { + return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil + } + } + title, err := OptionalParam[string](args, "title") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1144,32 +2237,35 @@ 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) 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 +2308,7 @@ 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) { +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) (*mcp.CallToolResult, error) { // Create the issue request with only provided fields issueRequest := &github.IssueRequest{} @@ -1241,6 +2337,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, @@ -1337,7 +2458,11 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 return utils.NewToolResultText(string(r)), nil } -// ListIssues creates a tool to list and filter repository issues +// ListIssues creates a tool to list and filter repository issues. This variant is +// gated by FeatureFlagIssueFields and exposes the Issues 2.0 field_filters input +// plus field_values output enrichment. When the flag is off, LegacyListIssues is +// served instead. Both registrations share the tool name "list_issues" and rely on +// the inventory's feature-flag filter to make exactly one active at a time. func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", @@ -1376,12 +2501,30 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { Type: "string", Description: "Filter by date (ISO 8601 timestamp)", }, + "field_filters": { + Type: "array", + Description: "Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date).", + Items: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "field_name": { + Type: "string", + Description: "Name of the custom field (e.g. \"Priority\"). Case-insensitive.", + }, + "value": { + Type: "string", + Description: "Value to filter on. For single-select fields, the option name (e.g. \"P1\"). For dates, YYYY-MM-DD. For numbers, the numeric value as a string. For text, the text value.", + }, + }, + Required: []string{"field_name", "value"}, + }, + }, }, Required: []string{"owner", "repo"}, } WithCursorPagination(schema) - return NewTool( + st := NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issues", @@ -1471,6 +2614,11 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } hasLabels := len(labels) > 0 + rawFilters, err := parseRawFieldFilters(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + // Get pagination parameters and convert to GraphQL format pagination, err := OptionalCursorPaginationParams(args) if err != nil { @@ -1502,13 +2650,28 @@ 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{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "states": states, - "orderBy": githubv4.IssueOrderField(orderBy), - "direction": githubv4.OrderDirection(direction), - "first": githubv4.Int(*paginationParams.First), + // 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, } if paginationParams.After != nil { @@ -1533,7 +2696,11 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } issueQuery := getIssueQueryType(hasLabels, hasSince) - if err := client.Query(ctx, issueQuery, vars); err != nil { + // The list_issues query references the issue_fields-gated IssueFieldValueFilter + // input type unconditionally, so we always opt into the feature via header. This + // is a no-op once the flags are globally rolled out. + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") + if err := client.Query(ctxWithFeatures, issueQuery, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse( ctx, "failed to list issues", @@ -1541,247 +2708,337 @@ 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.(IssueQueryResult); ok { + resp = convertToMinimalIssuesResponse(queryResult.GetIssueFragment()) + 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)) + result := MarshalledTextResult(resp) + if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + if result.Meta == nil { + result.Meta = mcp.Meta{} } - 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) + result.Meta["ifc"] = ifc.LabelListIssues(isPrivate) } - return utils.NewToolResultText(string(out)), nil, nil + return result, nil, nil }) + st.FeatureFlagEnable = 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 -} - -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() -} - -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", +// LegacyListIssues is the FeatureFlagIssueFields-disabled variant of list_issues. +// It exposes the pre-Issues-2.0 schema (no field_filters) and uses a GraphQL query +// path that does not select issueFieldValues or pass the issue_fields filter, so +// the request does not depend on server-side issue_fields features and does not pay +// for custom field values when the flag is off. Both this and ListIssues register +// under the tool name "list_issues"; exactly one is active for any given request +// thanks to mutually exclusive FeatureFlagEnable / FeatureFlagDisable annotations. +// Delete this function (and the rest of the Legacy* block) when the flag is removed. +func LegacyListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "state": { + Type: "string", + Description: "Filter by state, by default both open and closed issues are returned when not provided", + Enum: []any{"OPEN", "CLOSED"}, + }, + "labels": { + Type: "array", + Description: "Filter by labels", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "orderBy": { + Type: "string", + Description: "Order issues by field. If provided, the 'direction' also needs to be provided.", + Enum: []any{"CREATED_AT", "UPDATED_AT", "COMMENTS"}, + }, + "direction": { + Type: "string", + Description: "Order direction. If provided, the 'orderBy' also needs to be provided.", + Enum: []any{"ASC", "DESC"}, + }, + "since": { + Type: "string", + Description: "Filter by date (ISO 8601 timestamp)", + }, }, + Required: []string{"owner", "repo"}, } + WithCursorPagination(schema) - return NewTool( + st := NewTool( ToolsetMetadataIssues, mcp.Tool{ - Name: "assign_copilot_to_issue", - Description: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String()), - Icons: octicons.Icons("copilot"), + 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_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", - }, - }, - Required: []string{"owner", "repo", "issue_number"}, + 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) { - var params struct { - Owner string `mapstructure:"owner"` - Repo string `mapstructure:"repo"` - IssueNumber int32 `mapstructure:"issue_number"` + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil } - if err := mapstructure.Decode(args, ¶ms); err != nil { + repo, err := RequiredParam[string](args, "repo") + if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - client, err := deps.GetGQLClient(ctx) + state, err := OptionalParam[string](args, "state") if err != nil { - return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + return utils.NewToolResultError(err.Error()), nil, nil } - - // 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"` + 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} } - 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)"` + labels, err := OptionalStringArrayParam(args, "labels") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil } - variables := map[string]any{ - "owner": githubv4.String(params.Owner), - "name": githubv4.String(params.Repo), - "endCursor": (*githubv4.String)(nil), + orderBy, err := OptionalParam[string](args, "orderBy") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + direction, err := OptionalParam[string](args, "direction") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + orderBy = strings.ToUpper(orderBy) + switch orderBy { + case "CREATED_AT", "UPDATED_AT", "COMMENTS": + default: + orderBy = "CREATED_AT" + } + direction = strings.ToUpper(direction) + switch direction { + case "ASC", "DESC": + default: + direction = "DESC" } - var copilotAssignee *botAssignee - for { - var query suggestedActorsQuery - err := client.Query(ctx, &query, variables) + since, err := OptionalParam[string](args, "since") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + var sinceTime time.Time + var hasSince bool + if since != "" { + sinceTime, err = parseISOTimestamp(since) if err != nil { - return 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 + return utils.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil, nil } - variables["endCursor"] = githubv4.String(query.Repository.SuggestedActors.PageInfo.EndCursor) + hasSince = true } + hasLabels := len(labels) > 0 - // 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 + pagination, err := OptionalCursorPaginationParams(args) + if err != nil { + return nil, nil, err + } + if _, pageProvided := args["page"]; pageProvided { + return utils.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil, nil + } + _, perPageProvided := args["perPage"] + paginationExplicit := perPageProvided + paginationParams, err := pagination.ToGraphQLParams() + if err != nil { + return nil, nil, err + } + if !paginationExplicit { + defaultFirst := int32(DefaultGraphQLPageSize) + paginationParams.First = &defaultFirst } - // Next let's get the GQL Node ID and current assignees for this issue because the only way to - // assign copilot is to use replaceActorsForAssignable which requires the full list. - var getIssueQuery struct { - Repository struct { - 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)"` + client, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } - variables = map[string]any{ - "owner": githubv4.String(params.Owner), - "name": githubv4.String(params.Repo), - "number": githubv4.Int(params.IssueNumber), + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "states": states, + "orderBy": githubv4.IssueOrderField(orderBy), + "direction": githubv4.OrderDirection(direction), + "first": githubv4.Int(*paginationParams.First), + } + if paginationParams.After != nil { + vars["after"] = githubv4.String(*paginationParams.After) + } else { + vars["after"] = (*githubv4.String)(nil) + } + if hasLabels { + labelStrings := make([]githubv4.String, len(labels)) + for i, label := range labels { + labelStrings[i] = githubv4.String(label) + } + vars["labels"] = labelStrings + } + if hasSince { + vars["since"] = githubv4.DateTime{Time: sinceTime} } - if err := client.Query(ctx, &getIssueQuery, variables); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue ID", err), nil, nil + issueQuery := getLegacyIssueQueryType(hasLabels, hasSince) + if err := client.Query(ctx, issueQuery, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse( + ctx, + "failed to list issues", + err, + ), nil, nil } - // Finally, do the assignment. Just for reference, assigning copilot to an issue that it is already - // assigned to seems to have no impact (which is a good thing). - var assignCopilotMutation struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` // Not required but we need a selector or GQL errors - } `graphql:"replaceActorsForAssignable(input: $input)"` + var resp MinimalIssuesResponse + var isPrivate bool + if queryResult, ok := issueQuery.(LegacyIssueQueryResult); ok { + resp = convertLegacyToMinimalIssuesResponse(queryResult.GetLegacyIssueFragment()) + isPrivate = queryResult.GetIsPrivate() } - actorIDs := make([]githubv4.ID, len(getIssueQuery.Repository.Issue.Assignees.Nodes)+1) - for i, node := range getIssueQuery.Repository.Issue.Assignees.Nodes { - actorIDs[i] = node.ID + result := MarshalledTextResult(resp) + if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + if result.Meta == nil { + result.Meta = mcp.Meta{} + } + result.Meta["ifc"] = ifc.LabelListIssues(isPrivate) } - actorIDs[len(getIssueQuery.Repository.Issue.Assignees.Nodes)] = copilotAssignee.ID + return result, nil, nil + }) + st.FeatureFlagDisable = []string{FeatureFlagIssueFields} + return st +} - if err := client.Mutate( - ctx, - &assignCopilotMutation, - ReplaceActorsForAssignableInput{ - AssignableID: getIssueQuery.Repository.Issue.ID, - ActorIDs: actorIDs, - }, - nil, - ); err != nil { - return nil, nil, fmt.Errorf("failed to replace actors for assignable: %w", err) +// rawFieldFilter is the user-supplied {field_name, value} pair before type resolution. +type rawFieldFilter struct { + Name string + Value string +} + +// parseRawFieldFilters extracts the optional field_filters parameter into a list of +// {name, value} pairs. The value is always a string here; type-aware coercion happens +// later in resolveFieldFilters once we know each field's data_type. +func parseRawFieldFilters(args map[string]any) ([]rawFieldFilter, error) { + raw, ok := args["field_filters"] + if !ok { + return nil, nil + } + + var entries []map[string]any + switch v := raw.(type) { + case []any: + for _, f := range v { + entry, ok := f.(map[string]any) + if !ok { + return nil, fmt.Errorf("each field_filters entry must be an object") } + entries = append(entries, entry) + } + case []map[string]any: + entries = v + default: + return nil, fmt.Errorf("field_filters must be an array") + } - return utils.NewToolResultText("successfully assigned copilot to issue"), nil, nil - }) + filters := make([]rawFieldFilter, 0, len(entries)) + for _, entry := range entries { + fieldName, err := RequiredParam[string](entry, "field_name") + if err != nil { + return nil, fmt.Errorf("field_filters entry: %s", err.Error()) + } + value, err := RequiredParam[string](entry, "value") + if err != nil { + return nil, fmt.Errorf("field_filters entry %q: %s", fieldName, err.Error()) + } + filters = append(filters, rawFieldFilter{Name: fieldName, Value: value}) + } + return filters, nil } -type ReplaceActorsForAssignableInput struct { - AssignableID githubv4.ID `json:"assignableId"` - ActorIDs []githubv4.ID `json:"actorIds"` +// resolveFieldFilters matches each raw filter against a known field definition and +// coerces the value into the right typed slot on IssueFieldValueFilter. Matching is +// case-insensitive on field name; option names are also matched case-insensitively for +// single-select fields. +func resolveFieldFilters(rawFilters []rawFieldFilter, fields []IssueField) ([]IssueFieldValueFilter, error) { + byName := make(map[string]IssueField, len(fields)) + knownNames := make([]string, 0, len(fields)) + for _, f := range fields { + byName[strings.ToLower(f.Name)] = f + knownNames = append(knownNames, f.Name) + } + + out := make([]IssueFieldValueFilter, 0, len(rawFilters)) + for _, rf := range rawFilters { + field, ok := byName[strings.ToLower(rf.Name)] + if !ok { + return nil, fmt.Errorf("field_filters: unknown field %q. Known fields: %s", rf.Name, strings.Join(knownNames, ", ")) + } + + filter := IssueFieldValueFilter{FieldName: githubv4.String(field.Name)} + switch field.DataType { + case "SINGLE_SELECT": + // Validate the option name against the field's options so we fail fast + // with a useful error instead of an opaque GraphQL one. + var matched string + for _, o := range field.Options { + if strings.EqualFold(o.Name, rf.Value) { + matched = o.Name + break + } + } + if matched == "" { + optionNames := make([]string, 0, len(field.Options)) + for _, o := range field.Options { + optionNames = append(optionNames, o.Name) + } + return nil, fmt.Errorf("field_filters: %q is not a valid option for %q. Valid options: %s", rf.Value, field.Name, strings.Join(optionNames, ", ")) + } + v := githubv4.String(matched) + filter.SingleSelectOptionValue = &v + case "TEXT": + v := githubv4.String(rf.Value) + filter.TextValue = &v + case "DATE": + if _, err := time.Parse("2006-01-02", rf.Value); err != nil { + return nil, fmt.Errorf("field_filters: %q is not a valid date for %q (expected YYYY-MM-DD): %s", rf.Value, field.Name, err.Error()) + } + v := githubv4.String(rf.Value) + filter.DateValue = &v + case "NUMBER": + n, err := strconv.ParseFloat(rf.Value, 64) + if err != nil { + return nil, fmt.Errorf("field_filters: %q is not a valid number for %q: %s", rf.Value, field.Name, err.Error()) + } + v := githubv4.Float(n) + filter.NumberValue = &v + default: + return nil, fmt.Errorf("field_filters: field %q has unsupported data_type %q", field.Name, field.DataType) + } + out = append(out, filter) + } + return out, nil } // parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. @@ -1807,65 +3064,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 - }, - ) -} diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go new file mode 100644 index 0000000000..73fa75413c --- /dev/null +++ b/pkg/github/issues_granular.go @@ -0,0 +1,1145 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "strings" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +// issueUpdateTool is a helper to create single-field issue update tools. +func issueUpdateTool( + t translations.TranslationHelperFunc, + name, description, title string, + extraProps map[string]*jsonschema.Schema, + extraRequired []string, + buildRequest func(args map[string]any) (*github.IssueRequest, error), +) inventory.ServerTool { + props := map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "The issue number to update", + Minimum: jsonschema.Ptr(1.0), + }, + } + maps.Copy(props, extraProps) + + required := append([]string{"owner", "repo", "issue_number"}, extraRequired...) + + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: name, + Description: t("TOOL_"+strings.ToUpper(name)+"_DESCRIPTION", description), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_"+strings.ToUpper(name)+"_USER_TITLE", title), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: props, + Required: required, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + issueReq, err := buildRequest(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + issue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueReq) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%d", issue.GetID()), + URL: issue.GetHTMLURL(), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st +} + +// GranularCreateIssue creates a tool to create a new issue. +func GranularCreateIssue(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "create_issue", + Description: t("TOOL_CREATE_ISSUE_DESCRIPTION", "Create a new issue in a GitHub repository with a title and optional body."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_ISSUE_USER_TITLE", "Create Issue"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "title": { + Type: "string", + Description: "Issue title", + }, + "body": { + Type: "string", + Description: "Issue body content (optional)", + }, + }, + Required: []string{"owner", "repo", "title"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + title, err := RequiredParam[string](args, "title") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, _ := OptionalParam[string](args, "body") + + issueReq := &github.IssueRequest{ + Title: &title, + } + if body != "" { + issueReq.Body = &body + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + issue, resp, err := client.Issues.Create(ctx, owner, repo, issueReq) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create issue", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%d", issue.GetID()), + URL: issue.GetHTMLURL(), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st +} + +// GranularUpdateIssueTitle creates a tool to update an issue's title. +func GranularUpdateIssueTitle(t translations.TranslationHelperFunc) inventory.ServerTool { + return issueUpdateTool(t, + "update_issue_title", + "Update the title of an existing issue.", + "Update Issue Title", + map[string]*jsonschema.Schema{ + "title": {Type: "string", Description: "The new title for the issue"}, + }, + []string{"title"}, + func(args map[string]any) (*github.IssueRequest, error) { + title, err := RequiredParam[string](args, "title") + if err != nil { + return nil, err + } + return &github.IssueRequest{Title: &title}, nil + }, + ) +} + +// GranularUpdateIssueBody creates a tool to update an issue's body. +func GranularUpdateIssueBody(t translations.TranslationHelperFunc) inventory.ServerTool { + return issueUpdateTool(t, + "update_issue_body", + "Update the body content of an existing issue.", + "Update Issue Body", + map[string]*jsonschema.Schema{ + "body": {Type: "string", Description: "The new body content for the issue"}, + }, + []string{"body"}, + func(args map[string]any) (*github.IssueRequest, error) { + body, err := RequiredParam[string](args, "body") + if err != nil { + return nil, err + } + return &github.IssueRequest{Body: &body}, nil + }, + ) +} + +// GranularUpdateIssueAssignees creates a tool to update an issue's assignees. +func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventory.ServerTool { + return issueUpdateTool(t, + "update_issue_assignees", + "Update the assignees of an existing issue. This replaces the current assignees with the provided list.", + "Update Issue Assignees", + map[string]*jsonschema.Schema{ + "assignees": { + Type: "array", + Description: "GitHub usernames to assign to this issue", + Items: &jsonschema.Schema{Type: "string"}, + }, + }, + []string{"assignees"}, + func(args map[string]any) (*github.IssueRequest, error) { + if _, ok := args["assignees"]; !ok { + return nil, fmt.Errorf("missing required parameter: assignees") + } + assignees, err := OptionalStringArrayParam(args, "assignees") + if err != nil { + return nil, err + } + return &github.IssueRequest{Assignees: &assignees}, nil + }, + ) +} + +// labelWithRationale represents the object form of a label entry, allowing a +// rationale and/or suggest flag to be sent alongside the label name. +type labelWithRationale struct { + Name string `json:"name"` + Rationale string `json:"rationale,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 labelWithRationale 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."), + 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), + }, + "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 + } + isSuggestion, err := OptionalParam[bool](v, "is_suggestion") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if rationale == "" && !isSuggestion { + payload = append(payload, name) + } else { + useObjectForm = true + payload = append(payload, labelWithRationale{Name: name, Rationale: rationale, Suggest: isSuggestion}) + } + default: + return utils.NewToolResultError("each label must be a string or an object with 'name' and optional 'rationale' 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 + }, + ) +} + +// issueTypeWithRationale represents the object form of the issue type field, +// allowing a rationale and/or suggest flag to be sent alongside the type name. +type issueTypeWithRationale struct { + Value string `json:"value"` + Rationale string `json:"rationale,omitempty"` + Suggest bool `json:"suggest,omitempty"` +} + +// issueTypeUpdateRequest is a custom request body for updating an issue type +// with an optional rationale, using the object form that the REST API accepts. +type issueTypeUpdateRequest struct { + Type issueTypeWithRationale `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')."), + 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), + }, + "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 + } + 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 { + body = &issueTypeUpdateRequest{ + Type: issueTypeWithRationale{ + Value: issueType, + Rationale: rationale, + 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"` + 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), + }, + "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)) + } + } + + isSuggestion, err := OptionalParam[bool](fieldMap, "is_suggestion") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if isSuggestion { + suggestVal := githubv4.Boolean(true) + input.Suggest = &suggestVal + } + + issueFields = append(issueFields, input) + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + + // Resolve issue node ID + issueID, _, err := fetchIssueIDs(ctx, gqlClient, owner, repo, issueNumber, 0) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue", err), nil, nil + } + + // Execute the setIssueFieldValue mutation + var mutation struct { + SetIssueFieldValue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + IssueFieldValues []struct { + TextValue struct { + Value string + } `graphql:"... on IssueFieldTextValue"` + SingleSelectValue struct { + Name string + } `graphql:"... on IssueFieldSingleSelectValue"` + DateValue struct { + Value string + } `graphql:"... on IssueFieldDateValue"` + NumberValue struct { + Value float64 + } `graphql:"... on IssueFieldNumberValue"` + } + } `graphql:"setIssueFieldValue(input: $input)"` + } + + mutationInput := SetIssueFieldValueInput{ + IssueID: issueID, + IssueFields: issueFields, + } + + if err := gqlClient.Mutate(ctx, &mutation, mutationInput, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to set issue field values", err), nil, nil + } + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", mutation.SetIssueFieldValue.Issue.ID), + URL: string(mutation.SetIssueFieldValue.Issue.URL), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st +} diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 2ccd4918ff..b04370976e 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -13,9 +13,10 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/lockdown" + "github.com/github/github-mcp-server/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/pkg/http/transport" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/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 +24,14 @@ import ( ) var defaultGQLClient *githubv4.Client = githubv4.NewClient(newRepoAccessHTTPClient()) -var repoAccessCache *lockdown.RepoAccessCache = stubRepoAccessCache(defaultGQLClient, 15*time.Minute) type repoAccessKey struct { - owner string - repo string - username string + owner string + repo string } type repoAccessValue struct { - isPrivate bool - permission string + isPrivate bool } type repoAccessMockTransport struct { @@ -42,8 +40,8 @@ type repoAccessMockTransport struct { func newRepoAccessHTTPClient() *http.Client { responses := map[repoAccessKey]repoAccessValue{ - {owner: "owner2", repo: "repo2", username: "testuser2"}: {isPrivate: true}, - {owner: "owner", repo: "repo", username: "testuser"}: {isPrivate: false, permission: "READ"}, + {owner: "owner2", repo: "repo2"}: {isPrivate: true}, + {owner: "owner", repo: "repo"}: {isPrivate: false}, } return &http.Client{Transport: &repoAccessMockTransport{responses: responses}} @@ -66,33 +64,21 @@ func (rt *repoAccessMockTransport) RoundTrip(req *http.Request) (*http.Response, owner := toString(payload.Variables["owner"]) repo := toString(payload.Variables["name"]) - username := toString(payload.Variables["username"]) - value, ok := rt.responses[repoAccessKey{owner: owner, repo: repo, username: username}] + value, ok := rt.responses[repoAccessKey{owner: owner, repo: repo}] if !ok { - value = repoAccessValue{isPrivate: false, permission: "WRITE"} + value = repoAccessValue{isPrivate: false} } - edges := []any{} - if value.permission != "" { - edges = append(edges, map[string]any{ - "permission": value.permission, - "node": map[string]any{ - "login": username, - }, - }) + data := map[string]any{} + if strings.Contains(payload.Query, "viewer") { + data["viewer"] = map[string]any{"login": "test-viewer"} + } + if strings.Contains(payload.Query, "repository") { + data["repository"] = map[string]any{"isPrivate": value.isPrivate} } - responseBody, err := json.Marshal(map[string]any{ - "data": map[string]any{ - "repository": map[string]any{ - "isPrivate": value.isPrivate, - "collaborators": map[string]any{ - "edges": edges, - }, - }, - }, - }) + responseBody, err := json.Marshal(map[string]any{"data": data}) if err != nil { return nil, err } @@ -170,20 +156,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 +182,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 +196,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 +204,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 +220,24 @@ func Test_GetIssue(t *testing.T) { expectResultError: true, expectedErrMsg: "access to issue details is restricted by lockdown mode", lockdownEnabled: true, + restPermission: "read", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) - var gqlClient *githubv4.Client - cache := repoAccessCache - if tc.gqlHTTPClient != nil { - gqlClient = githubv4.NewClient(tc.gqlHTTPClient) - cache = stubRepoAccessCache(gqlClient, 15*time.Minute) - } else { - gqlClient = githubv4.NewClient(nil) + var restClient *github.Client + if tc.restPermission != "" { + restClient = mockRESTPermissionServer(t, tc.restPermission, nil) } + cache := stubRepoAccessCache(restClient, 15*time.Minute) flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}) deps := BaseDeps{ Client: client, - GQLClient: gqlClient, + GQLClient: defaultGQLClient, RepoAccessCache: cache, Flags: flags, } @@ -345,19 +263,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 untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(true, 0)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(commentsReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false, http.StatusInternalServerError)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(getReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails") + + if result.Meta != nil { + _, hasIFC := result.Meta["ifc"] + assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails") + } + }) +} + +func Test_GetIssue_FieldValues(t *testing.T) { + // Verify that issue_field_values from the REST API are NOT exposed when the + // remote_mcp_issue_fields flag is off. The raw REST format is always cleared; + // enriched field_values are only populated when the flag is on. + serverTool := IssueRead(translations.NullTranslationHelper) + + mockIssueWithFields := &github.Issue{ + Number: github.Ptr(99), + Title: github.Ptr("Issue with field values"), + Body: github.Ptr("body"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/99"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + IssueFieldValues: []*github.IssueFieldValue{ + { + IssueFieldID: 1001, + NodeID: "FV_node_1", + DataType: "single_select", + Value: "High", + SingleSelectOption: &github.IssueFieldValueSingleSelectOption{ + ID: 42, + Name: "High", + Color: "red", + }, + }, + { + IssueFieldID: 1002, + NodeID: "FV_node_2", + DataType: "text", + Value: "some text value", + }, + }, + } + + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssueWithFields), + }) + + cache := stubRepoAccessCache(nil, 15*time.Minute) + flags := stubFeatureFlags(map[string]bool{"lockdown-mode": false}) + deps := BaseDeps{ + Client: mustNewGHClient(t, mockedClient), + GQLClient: defaultGQLClient, + RepoAccessCache: cache, + Flags: flags, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "get", + "owner": "owner", + "repo": "repo", + "issue_number": float64(99), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.NotNil(t, result) + + textContent := getTextResult(t, result) + + var returnedIssue MinimalIssue + err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + require.NoError(t, err) + + // Flag is off: raw REST IssueFieldValues must be cleared, enriched FieldValues absent. + assert.Empty(t, returnedIssue.IssueFieldValues, "raw REST issue_field_values should not be exposed when flag is off") + assert.Empty(t, returnedIssue.FieldValues, "enriched field_values should not be present when flag is off") +} + +func Test_GetIssue_FieldValues_FlagOn(t *testing.T) { + // Verify the enriched field_values are populated via GraphQL when the + // remote_mcp_issue_fields flag is on, and the raw REST issue_field_values + // stays cleared. + serverTool := IssueRead(translations.NullTranslationHelper) + + mockIssueWithFields := &github.Issue{ + Number: github.Ptr(99), + NodeID: github.Ptr("I_node_99"), + Title: github.Ptr("Issue with field values"), + Body: github.Ptr("body"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/99"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + IssueFieldValues: []*github.IssueFieldValue{ + { + IssueFieldID: 1001, + NodeID: "FV_node_1", + DataType: "single_select", + Value: "High", + }, + }, + } + + restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssueWithFields), + }) + + gqlVars := map[string]any{ + "ids": []any{"I_node_99"}, + } + gqlResponse := githubv4mock.DataResponse(map[string]any{ + "nodes": []map[string]any{ + { + "id": "I_node_99", + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{ + { + "__typename": "IssueFieldSingleSelectValue", + "field": map[string]any{"name": "priority"}, + "value": "P1", + }, + { + "__typename": "IssueFieldNumberValue", + "field": map[string]any{"name": "estimate"}, + "valueNumber": 2.5, + }, + }, + }, + }, + }, + }) + + const nodesQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}}}}" + matcher := githubv4mock.NewQueryMatcher(nodesQueryString, gqlVars, gqlResponse) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + + cache := stubRepoAccessCache(nil, 15*time.Minute) + deps := BaseDeps{ + Client: mustNewGHClient(t, restClient), + GQLClient: gqlClient, + RepoAccessCache: cache, + featureChecker: featureCheckerFor(FeatureFlagIssueFields), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "get", + "owner": "owner", + "repo": "repo", + "issue_number": float64(99), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError, "expected result to not be an error") + + textContent := getTextResult(t, result) + + var returnedIssue MinimalIssue + err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + require.NoError(t, err) + + // Raw REST IssueFieldValues is always cleared, even when flag is on. + assert.Empty(t, returnedIssue.IssueFieldValues, "raw REST issue_field_values should not be exposed even when flag is on") + + // Enriched FieldValues comes from the GraphQL nodes() round-trip. + require.Len(t, returnedIssue.FieldValues, 2, "field_values should be populated from GraphQL when flag is on") + assert.Equal(t, "priority", returnedIssue.FieldValues[0].Field) + assert.Equal(t, "P1", returnedIssue.FieldValues[0].Value) + assert.Equal(t, "estimate", returnedIssue.FieldValues[1].Field) + assert.Equal(t, "2.5", returnedIssue.FieldValues[1].Value) +} + func Test_AddIssueComment(t *testing.T) { // Verify tool definition once serverTool := AddIssueComment(translations.NullTranslationHelper) @@ -386,7 +587,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 +597,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 +614,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 +628,7 @@ func Test_AddIssueComment(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -458,14 +659,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 +719,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 +740,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 +766,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 +790,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 +811,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 +823,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 +843,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 +863,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 +885,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 +905,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 +960,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 +971,7 @@ func Test_SearchIssues(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -777,71 +1017,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 +1358,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 +1373,77 @@ func Test_CreateIssue(t *testing.T) { State: github.Ptr("open"), }, }, + { + name: "successful issue creation with issue fields reconciled by names", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{ + "title": "Issue with fields", + "body": "", + "labels": []any{}, + "assignees": []any{}, + "issue_field_values": []any{ + map[string]any{"field_id": float64(101), "value": "P1"}, + map[string]any{"field_id": float64(102), "value": "Acme"}, + }, + }).andThen( + mockResponse(t, http.StatusCreated, &github.Issue{ + Number: github.Ptr(125), + Title: github.Ptr("Issue with fields"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/125"), + State: github.Ptr("open"), + }), + ), + }), + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + issueFieldWriteMetadataQuery{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "fullDatabaseId": "101", + "name": "Priority", + "dataType": "single_select", + "options": []any{ + map[string]any{"fullDatabaseId": "9001", "name": "P1"}, + }, + }, + map[string]any{ + "__typename": "IssueFieldText", + "fullDatabaseId": "102", + "name": "Customer", + "dataType": "text", + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Issue with fields", + "issue_fields": []any{ + map[string]any{"field_name": "Priority", "field_option_name": "P1"}, + map[string]any{"field_name": "Customer", "value": "Acme"}, + }, + }, + expectError: false, + expectedIssue: &github.Issue{ + Number: github.Ptr(125), + Title: github.Ptr("Issue with fields"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/125"), + State: github.Ptr("open"), + }, + }, { name: "issue creation fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -878,7 +1452,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 +1461,32 @@ func Test_CreateIssue(t *testing.T) { expectError: false, expectedErrMsg: "missing required parameter: title", }, + { + name: "issue_fields rejects both value and field_option_name", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Invalid fields", + "issue_fields": []any{ + map[string]any{"field_name": "Priority", "value": "P1", "field_option_name": "P1"}, + }, + }, + expectError: false, + expectedErrMsg: "cannot specify both value and field_option_name", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) - gqlClient := githubv4.NewClient(nil) + client := mustNewGHClient(t, tc.mockedClient) + gqlHTTPClient := tc.mockedGQLClient + if gqlHTTPClient == nil { + gqlHTTPClient = githubv4mock.NewMockedHTTPClient() + } + gqlClient := githubv4.NewClient(gqlHTTPClient) deps := BaseDeps{ Client: client, GQLClient: gqlClient, @@ -933,11 +1526,188 @@ func Test_CreateIssue(t *testing.T) { } } +// 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() + + mockIssue := &github.Issue{ + Number: github.Ptr(1), + Title: github.Ptr("Test"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/1"), + } + + 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) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "Ready to create an issue") + }) + + 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 skips form and executes directly", func(t *testing.T) { + mockBaseIssue := &github.Issue{ + Number: github.Ptr(1), + Title: github.Ptr("Test"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/1"), + } + issueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "id": "I_kwDOA0xdyM50BPaO", + }, + }, + }) + closeSuccessResponse := githubv4mock.DataResponse(map[string]any{ + "closeIssue": map[string]any{ + "issue": map[string]any{ + "id": "I_kwDOA0xdyM50BPaO", + "number": 1, + "url": "https://github.com/owner/repo/issues/1", + "state": "CLOSED", + }, + }, + }) + completedReason := IssueClosedStateReasonCompleted + + closeClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), + })) + closeGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + 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(1), + }, + issueIDQueryResponse, + ), + githubv4mock.NewMutationMatcher( + struct { + CloseIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + State githubv4.String + } + } `graphql:"closeIssue(input: $input)"` + }{}, + CloseIssueInput{ + IssueID: "I_kwDOA0xdyM50BPaO", + StateReason: &completedReason, + }, + nil, + closeSuccessResponse, + ), + )) + + closeDeps := BaseDeps{ + Client: closeClient, + GQLClient: closeGQLClient, + featureChecker: featureCheckerFor(MCPAppsFeatureFlag), + } + closeHandler := serverTool.Handler(closeDeps) + + 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 := closeHandler(ContextWithDeps(context.Background(), closeDeps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "Ready to update issue", + "state change should skip UI form") + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", + "state change should execute directly and return issue URL") + }) + + 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, "Ready to update issue #1", + "update without state should show UI form") + }) +} + func Test_ListIssues(t *testing.T) { // Verify tool definition serverTool := ListIssues(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueFields, tool)) + require.Equal(t, FeatureFlagIssueFields, serverTool.FeatureFlagEnable) assert.Equal(t, "list_issues", tool.Name) assert.NotEmpty(t, tool.Description) @@ -971,6 +1741,15 @@ func Test_ListIssues(t *testing.T) { "comments": map[string]any{ "totalCount": 5, }, + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{ + { + "__typename": "IssueFieldSingleSelectValue", + "field": map[string]any{"name": "priority"}, + "value": "P1", + }, + }, + }, }, { "number": 456, @@ -989,6 +1768,25 @@ func Test_ListIssues(t *testing.T) { "comments": map[string]any{ "totalCount": 3, }, + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{ + { + "__typename": "IssueFieldDateValue", + "field": map[string]any{"name": "due"}, + "value": "2026-06-01", + }, + { + "__typename": "IssueFieldNumberValue", + "field": map[string]any{"name": "estimate"}, + "valueNumber": 2.5, + }, + { + "__typename": "IssueFieldTextValue", + "field": map[string]any{"name": "notes"}, + "value": "needs triage", + }, + }, + }, }, } @@ -1009,6 +1807,9 @@ func Test_ListIssues(t *testing.T) { "comments": map[string]any{ "totalCount": 1, }, + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{}, + }, }, } @@ -1025,6 +1826,7 @@ func Test_ListIssues(t *testing.T) { }, "totalCount": 2, }, + "isPrivate": false, }, }) @@ -1040,6 +1842,7 @@ func Test_ListIssues(t *testing.T) { }, "totalCount": 2, }, + "isPrivate": false, }, }) @@ -1055,74 +1858,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 +1941,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 +1951,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 +1961,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 +1971,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 +1981,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", }, @@ -1180,88 +1990,752 @@ 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}}}" + // Define the actual query strings that match the implementation + issueFieldValuesSelection := "issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}" + qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,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) { + var httpClient *http.Client + + switch tc.name { + case "list all issues": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsListAll, mockResponseListAll) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by open state": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by open state - lc": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by closed state": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsClosedOnly, mockResponseClosedOnly) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by labels": + matcher := githubv4mock.NewQueryMatcher(qWithLabels, varsWithLabels, mockResponseListAll) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "repository not found error": + matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsRepoNotFound, mockErrorRepoNotFound) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + } + + gqlClient := githubv4.NewClient(httpClient) + deps := BaseDeps{ + GQLClient: gqlClient, + } + handler := serverTool.Handler(deps) + + req := createMCPRequest(tc.reqParams) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + text := getTextResult(t, res).Text + + if tc.expectError { + require.True(t, res.IsError) + assert.Contains(t, text, tc.errContains) + return + } + require.NoError(t, err) + + // Parse the structured response with pagination info + var response MinimalIssuesResponse + err = json.Unmarshal([]byte(text), &response) + require.NoError(t, err) + + assert.Len(t, response.Issues, tc.expectedCount, "Expected %d issues, got %d", tc.expectedCount, len(response.Issues)) + + // Verify pagination metadata + assert.Equal(t, tc.expectedCount, response.TotalCount) + assert.False(t, response.PageInfo.HasNextPage) + assert.False(t, response.PageInfo.HasPreviousPage) + + // Verify that returned issues have expected structure + for _, issue := range response.Issues { + assert.NotZero(t, issue.Number, "Issue should have number") + assert.NotEmpty(t, issue.Title, "Issue should have title") + assert.NotEmpty(t, issue.State, "Issue should have state") + assert.NotEmpty(t, issue.CreatedAt, "Issue should have created_at") + assert.NotEmpty(t, issue.UpdatedAt, "Issue should have updated_at") + assert.NotNil(t, issue.User, "Issue should have user") + assert.NotEmpty(t, issue.User.Login, "Issue user should have login") + assert.Empty(t, issue.HTMLURL, "html_url should be empty (not populated by GraphQL fragment)") + + // Labels should be flattened to name strings + for _, label := range issue.Labels { + assert.NotEmpty(t, label, "Label should be a non-empty string") + } + + // Field values should be flattened to {field, value} pairs. Issue #123 has a + // SingleSelectValue; issue #456 exercises the Date/Number/Text branches + // (including float formatting); #789 has no field values. + switch issue.Number { + case 123: + assert.Equal(t, []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_ListIssues_FieldFilters(t *testing.T) { + t.Parallel() + + serverTool := ListIssues(translations.NullTranslationHelper) + + mockIssues := []map[string]any{ + { + "number": 1, + "title": "An issue", + "body": "body", + "state": "OPEN", + "databaseId": 1, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "author": map[string]any{"login": "user1"}, + "labels": map[string]any{"nodes": []map[string]any{}}, + "comments": map[string]any{"totalCount": 0}, + }, + } + + pageInfo := map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + } + + response := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issues": map[string]any{ + "nodes": mockIssues, + "pageInfo": pageInfo, + "totalCount": 1, + }, + "isPrivate": false, + }, + }) + + // Field-lookup matcher used by every subtest that supplies field_filters. + // The handler calls fetchIssueFields(owner, repo) before issuing the issues query. + fieldsResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "id": "IFSS_1", + "name": "Priority", + "dataType": "SINGLE_SELECT", + "visibility": "ALL", + "options": []any{ + map[string]any{"id": "OPT_P1", "name": "P1", "color": "red"}, + map[string]any{"id": "OPT_P2", "name": "P2", "color": "yellow"}, + }, + }, + map[string]any{ + "__typename": "IssueFieldText", + "id": "IFT_1", + "name": "Notes", + "dataType": "TEXT", + "visibility": "ALL", + }, + map[string]any{ + "__typename": "IssueFieldNumber", + "id": "IFN_1", + "name": "Estimate", + "dataType": "NUMBER", + "visibility": "ALL", + }, + map[string]any{ + "__typename": "IssueFieldDate", + "id": "IFD_1", + "name": "Due", + "dataType": "DATE", + "visibility": "ALL", + }, + }, + }, + }, + }) + fieldsMatcher := func() githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + issueFieldsRepoQuery{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + }, + fieldsResponse, + ) + } + + qNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,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") - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - var httpClient *http.Client + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) - switch tc.name { - case "list all issues": - matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsListAll, mockResponseListAll) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "filter by open state": - matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "filter by open state - lc": - matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "filter by closed state": - matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsClosedOnly, mockResponseClosedOnly) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "filter by labels": - matcher := githubv4mock.NewQueryMatcher(qWithLabels, varsWithLabels, mockResponseListAll) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - case "repository not found error": - matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsRepoNotFound, mockErrorRepoNotFound) - httpClient = githubv4mock.NewMockedHTTPClient(matcher) - } + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) - gqlClient := githubv4.NewClient(httpClient) - deps := BaseDeps{ - GQLClient: gqlClient, - } - handler := serverTool.Handler(deps) + t.Run("insiders mode enabled on private repo emits private untrusted label", func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(true)) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + deps := BaseDeps{ + GQLClient: gqlClient, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) - req := createMCPRequest(tc.reqParams) - res, err := handler(ContextWithDeps(context.Background(), deps), &req) - text := getTextResult(t, res).Text + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) - if tc.expectError { - require.True(t, res.IsError) - assert.Contains(t, text, tc.errContains) - return - } - require.NoError(t, err) + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") - // 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"` - } - err = json.Unmarshal([]byte(text), &response) - require.NoError(t, err) + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) - assert.Len(t, response.Issues, tc.expectedCount, "Expected %d issues, got %d", tc.expectedCount, len(response.Issues)) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) +} - // Verify order if verifyOrder function is provided - if tc.verifyOrder != nil { - tc.verifyOrder(t, response.Issues) - } +func Test_LegacyListIssues_Definition(t *testing.T) { + serverTool := LegacyListIssues(translations.NullTranslationHelper) + tool := serverTool.Tool - // 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") - } - }) + // 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 + // Verify tool definition (flag-enabled variant snap) serverTool := IssueWrite(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueFields, tool)) assert.Equal(t, "issue_write", tool.Name) assert.NotEmpty(t, tool.Description) @@ -1278,6 +2752,7 @@ func Test_UpdateIssue(t *testing.T) { assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state_reason") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "duplicate_of") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_fields") assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo"}) // Mock issues for reuse across test cases @@ -1362,7 +2837,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 +2845,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 +2853,7 @@ func Test_UpdateIssue(t *testing.T) { ), }), mockedGQLClient: githubv4mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "update", "owner": "owner", "repo": "repo", @@ -1389,6 +2864,81 @@ func Test_UpdateIssue(t *testing.T) { expectError: false, expectedIssue: mockUpdatedIssue, }, + { + name: "partial update with issue fields reconciled by names", + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ + "issue_field_values": []any{ + map[string]any{"field_id": float64(101), "value": "P1"}, + map[string]any{"field_id": float64(102), "value": "Acme"}, + }, + "title": "Updated Title", + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedIssue), + ), + }), + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + // fetch-and-merge: returns no existing fields so the incoming values are used as-is + githubv4mock.NewQueryMatcher( + "query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){issue(number: $number){issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}}}}", + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "issueFieldValues": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + issueFieldWriteMetadataQuery{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "fullDatabaseId": "101", + "name": "Priority", + "dataType": "single_select", + "options": []any{map[string]any{"fullDatabaseId": "9001", "name": "P1"}}, + }, + map[string]any{ + "__typename": "IssueFieldText", + "fullDatabaseId": "102", + "name": "Customer", + "dataType": "text", + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "title": "Updated Title", + "issue_fields": []any{ + map[string]any{"field_name": "Priority", "field_option_name": "P1"}, + map[string]any{"field_name": "Customer", "value": "Acme"}, + }, + }, + expectError: false, + expectedIssue: mockUpdatedIssue, + }, { name: "issue not found when updating non-state fields only", mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -1398,7 +2948,7 @@ func Test_UpdateIssue(t *testing.T) { }), }), mockedGQLClient: githubv4mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "update", "owner": "owner", "repo": "repo", @@ -1453,7 +3003,7 @@ func Test_UpdateIssue(t *testing.T) { closeSuccessResponse, ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "update", "owner": "owner", "repo": "repo", @@ -1504,7 +3054,7 @@ func Test_UpdateIssue(t *testing.T) { reopenSuccessResponse, ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "update", "owner": "owner", "repo": "repo", @@ -1536,7 +3086,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 +3123,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 +3138,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 +3199,7 @@ func Test_UpdateIssue(t *testing.T) { closeSuccessResponse, ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "update", "owner": "owner", "repo": "repo", @@ -1671,7 +3221,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 +3238,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, @@ -1822,8 +3372,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 +3383,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 +3402,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 +3418,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 +3443,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,18 +3464,19 @@ func Test_GetIssueComments(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) - var gqlClient *githubv4.Client - if tc.gqlHTTPClient != nil { - gqlClient = githubv4.NewClient(tc.gqlHTTPClient) - } else { - gqlClient = githubv4.NewClient(nil) + client := mustNewGHClient(t, tc.mockedClient) + var restClient *github.Client + if tc.lockdownEnabled { + restClient = mockRESTPermissionServer(t, "read", map[string]string{ + "maintainer": "write", + "testuser": "read", + }) } - cache := stubRepoAccessCache(gqlClient, 15*time.Minute) + cache := stubRepoAccessCache(restClient, 15*time.Minute) flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}) deps := BaseDeps{ Client: client, - GQLClient: gqlClient, + GQLClient: defaultGQLClient, RepoAccessCache: cache, Flags: flags, } @@ -1950,151 +3499,36 @@ func Test_GetIssueComments(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var returnedComments []*github.IssueComment + 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].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) + 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 TestAssignCopilotToIssue(t *testing.T) { +func Test_GetIssueLabels(t *testing.T) { t.Parallel() // Verify tool definition - serverTool := AssignCopilotToIssue(translations.NullTranslationHelper) + serverTool := IssueRead(translations.NullTranslationHelper) tool := serverTool.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "assign_copilot_to_issue", tool.Name) + 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{"owner", "repo", "issue_number"}) - - 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 - } + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number"}) tests := []struct { name string @@ -2104,393 +3538,85 @@ func TestAssignCopilotToIssue(t *testing.T) { 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 { - 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{ - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "assignees": map[string]any{ - "nodes": []any{}, - }, - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` - } `graphql:"replaceActorsForAssignable(input: $input)"` - }{}, - ReplaceActorsForAssignableInput{ - AssignableID: githubv4.ID("test-issue-id"), - ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, - }, - nil, - githubv4mock.DataResponse(map[string]any{}), - ), - ), - }, - { - name: "successful assignment when there are existing assignees", + 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 { - 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 { 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{ "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "assignees": map[string]any{ + "labels": map[string]any{ "nodes": []any{ map[string]any{ - "id": githubv4.ID("existing-assignee-id"), - }, - map[string]any{ - "id": githubv4.ID("existing-assignee-id-2"), + "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.NewMutationMatcher( - struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` - } `graphql:"replaceActorsForAssignable(input: $input)"` - }{}, - ReplaceActorsForAssignableInput{ - AssignableID: githubv4.ID("test-issue-id"), - ActorIDs: []githubv4.ID{ - githubv4.ID("existing-assignee-id"), - githubv4.ID("existing-assignee-id-2"), - githubv4.ID("copilot-swe-agent-id"), - }, - }, - nil, - githubv4mock.DataResponse(map[string]any{}), - ), - ), - }, - { - 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 { - 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{ - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "assignees": map[string]any{ - "nodes": []any{}, - }, - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` - } `graphql:"replaceActorsForAssignable(input: $input)"` - }{}, - ReplaceActorsForAssignableInput{ - AssignableID: githubv4.ID("test-issue-id"), - ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, - }, - nil, - githubv4mock.DataResponse(map[string]any{}), - ), - ), - }, - { - 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.", + 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) - - // Call handler result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - textContent := getTextResult(t, result) + require.NoError(t, err) + 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)) - require.Equal(t, textContent.Text, "successfully assigned copilot to issue") }) } } @@ -2533,7 +3659,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 @@ -2543,7 +3669,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", @@ -2559,7 +3685,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", @@ -2574,7 +3700,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", @@ -2590,7 +3716,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", @@ -2605,7 +3731,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", @@ -2620,7 +3746,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", @@ -2635,7 +3761,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", @@ -2648,7 +3774,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), @@ -2660,7 +3786,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", @@ -2674,7 +3800,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, } @@ -2772,7 +3898,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 @@ -2782,7 +3908,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", @@ -2801,7 +3927,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", @@ -2817,7 +3943,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", @@ -2831,7 +3957,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", @@ -2845,7 +3971,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", @@ -2859,7 +3985,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", @@ -2871,7 +3997,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), @@ -2882,7 +4008,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", @@ -2895,12 +4021,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) @@ -2990,7 +4116,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 @@ -3000,7 +4126,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", @@ -3015,7 +4141,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", @@ -3030,7 +4156,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", @@ -3045,7 +4171,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", @@ -3060,7 +4186,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", @@ -3075,7 +4201,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", @@ -3088,7 +4214,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), @@ -3100,7 +4226,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", @@ -3114,7 +4240,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, } @@ -3198,7 +4324,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 @@ -3208,7 +4334,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", @@ -3224,7 +4350,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", @@ -3238,7 +4364,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", @@ -3251,7 +4377,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", @@ -3268,7 +4394,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", @@ -3284,7 +4410,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", @@ -3300,7 +4426,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", @@ -3316,7 +4442,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", @@ -3332,7 +4458,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", @@ -3346,7 +4472,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), @@ -3359,7 +4485,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", @@ -3374,7 +4500,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, } @@ -3449,7 +4575,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 @@ -3459,7 +4585,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, @@ -3470,7 +4596,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, @@ -3481,7 +4607,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{}, expectError: false, // This should be handled by parameter validation, error returned in result expectedErrMsg: "missing required parameter: owner", }, @@ -3490,7 +4616,7 @@ func Test_ListIssueTypes(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/labels.go b/pkg/github/labels.go index 0dbb622d91..e8d8102cbf 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -24,7 +24,7 @@ func GetLabel(t translations.TranslationHelperFunc) inventory.ServerTool { Name: "get_label", Description: t("TOOL_GET_LABEL_DESCRIPTION", "Get a specific label from a repository."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_LABEL_TITLE", "Get a specific label from a repository."), + Title: t("TOOL_GET_LABEL_TITLE", "Get a specific label from a repository"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ @@ -126,7 +126,7 @@ func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { Name: "list_label", Description: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository"), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository."), + Title: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ @@ -217,7 +217,7 @@ func LabelWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Name: "label_write", Description: t("TOOL_LABEL_WRITE_DESCRIPTION", "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LABEL_WRITE_TITLE", "Write operations on repository labels."), + Title: t("TOOL_LABEL_WRITE_TITLE", "Write operations on repository labels"), ReadOnlyHint: false, }, InputSchema: &jsonschema.Schema{ diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index b055efb38a..a93d29ead5 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"` @@ -77,6 +110,18 @@ type MinimalCommitFile struct { Changes int `json:"changes,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"` +} + // MinimalCommit is the trimmed output type for commit objects. type MinimalCommit struct { SHA string `json:"sha"` @@ -88,6 +133,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 +170,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 +184,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"` @@ -131,10 +206,629 @@ type MinimalProject struct { Number *int `json:"number,omitempty"` ShortDescription *string `json:"short_description,omitempty"` DeletedBy *MinimalUser `json:"deleted_by,omitempty"` + OwnerType string `json:"owner_type,omitempty"` +} + +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 @@ -158,6 +852,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 @@ -171,57 +1406,73 @@ 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(), } } + return minimalCommit +} + +// convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit +func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit { + minimalCommit := newMinimalCommitFromCore( + commit.GetSHA(), + commit.GetHTMLURL(), + commit.Commit, + commit.Author, + commit.Committer, + ) + // Only include stats and files if includeDiffs is true if includeDiffs { if commit.Stats != nil { @@ -250,6 +1501,83 @@ func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) 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 +} + // convertToMinimalBranch converts a GitHub API Branch to MinimalBranch func convertToMinimalBranch(branch *github.Branch) MinimalBranch { return MinimalBranch{ @@ -258,3 +1586,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 1de24fb0dc..61d8f40b2e 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "net/http" - "strconv" "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -14,7 +13,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -209,13 +208,7 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT var resp *github.Response switch state { case "done": - // for some inexplicable reason, the API seems to have threadID as int64 and string depending on the endpoint - var threadIDInt int64 - threadIDInt, err = strconv.ParseInt(threadID, 10, 64) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid threadID format: %v", err)), nil, nil - } - resp, err = client.Activity.MarkThreadDone(ctx, threadIDInt) + resp, err = client.Activity.MarkThreadDone(ctx, threadID) case "read": resp, err = client.Activity.MarkThreadRead(ctx, threadID) default: @@ -231,7 +224,7 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT } defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusResetContent && resp.StatusCode != http.StatusOK { + if resp.StatusCode != http.StatusResetContent && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go index 936a70df43..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", }, @@ -472,11 +471,11 @@ func Test_DismissNotification(t *testing.T) { expectRead: true, }, { - name: "mark as done", + name: "mark as done with 204 response", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - DeleteNotificationsThreadsByThreadID: mockResponse(t, http.StatusOK, nil), + DeleteNotificationsThreadsByThreadID: mockResponse(t, http.StatusNoContent, nil), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "threadID": "123", "state": "done", }, @@ -484,19 +483,21 @@ func Test_DismissNotification(t *testing.T) { expectDone: true, }, { - name: "invalid threadID format", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ - "threadID": "notanumber", + name: "mark as done with 200 response", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteNotificationsThreadsByThreadID: mockResponse(t, http.StatusOK, nil), + }), + requestArgs: map[string]any{ + "threadID": "123", "state": "done", }, - expectError: false, - expectInvalid: true, + expectError: false, + expectDone: true, }, { name: "missing required threadID", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "state": "read", }, expectError: true, @@ -504,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, @@ -512,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", }, @@ -522,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, } @@ -540,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: @@ -559,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") - } }) } } @@ -585,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 @@ -595,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, }, @@ -604,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, @@ -615,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", }, @@ -627,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", }, @@ -635,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, } @@ -682,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 @@ -692,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, @@ -703,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, @@ -713,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..ecdc8c3549 --- /dev/null +++ b/pkg/github/params.go @@ -0,0 +1,474 @@ +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 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 + } + 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 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 8af181a729..9c7310c0ff 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -6,68 +6,187 @@ import ( "fmt" "io" "net/http" - "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/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 ) -// FeatureFlagConsolidatedProjects is the feature flag that disables individual project tools -// in favor of the consolidated project tools. -const FeatureFlagConsolidatedProjects = "remote_mcp_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" + 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" ) -func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { +// GraphQL types for ProjectV2 status updates + +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 + } +} + +type statusUpdateConnection struct { + Nodes []statusUpdateNode + PageInfo PageInfoFragment +} + +// statusUpdatesUserQuery is the GraphQL query for listing status updates on a user-owned project. +type statusUpdatesUserQuery struct { + User struct { + ProjectV2 struct { + StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` +} + +// statusUpdatesOrgQuery is the GraphQL query for listing status updates on an org-owned project. +type statusUpdatesOrgQuery struct { + Organization struct { + ProjectV2 struct { + StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` +} + +// statusUpdateNodeQuery is the GraphQL query for fetching a single status update by node ID. +type statusUpdateNodeQuery struct { + Node struct { + StatusUpdate statusUpdateNode `graphql:"... on ProjectV2StatusUpdate"` + } `graphql:"node(id: $id)"` +} + +// 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"` +} + +// 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, +} + +func convertToMinimalStatusUpdate(node statusUpdateNode) MinimalProjectStatusUpdate { + var creator *MinimalUser + if login := string(node.Creator.Login); login != "" { + creator = &MinimalUser{Login: login} + } + + 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, + } +} + +func derefString(s *githubv4.String) string { + if s == nil { + return "" + } + return string(*s) +} + +// ProjectsList returns the tool and handler for listing GitHub Projects resources. +func ProjectsList(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`), + Name: "projects_list", + Description: t("TOOL_PROJECTS_LIST_DESCRIPTION", + `Tools for listing GitHub Projects resources. +Use this tool to list projects for a user or organization, or list project fields and items for a specific project. +`), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"), + Title: t("TOOL_PROJECTS_LIST_USER_TITLE", "List GitHub Projects resources"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The action to perform", + Enum: []any{ + projectsMethodListProjects, + projectsMethodListProjectFields, + projectsMethodListProjectItems, + projectsMethodListProjectStatusUpdates, + }, + }, "owner_type": { Type: "string", - Description: "Owner type", + Description: "Owner type (user or org). If not provided, will automatically try both.", 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.", + Description: "The owner (user or organization login). The name is not case sensitive.", + }, + "project_number": { + Type: "number", + Description: "The project's number. Required for 'list_project_fields', 'list_project_items', and 'list_project_status_updates' methods.", }, "query": { Type: "string", - Description: `Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: "roadmap is:open", "is:open feature planning".`, + Description: `Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax.`, + }, + "fields": { + Type: "array", + Description: "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method.", + Items: &jsonschema.Schema{ + Type: "string", + }, }, "per_page": { Type: "number", @@ -82,28 +201,22 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", }, }, - Required: []string{"owner_type", "owner"}, + Required: []string{"method", "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") + method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - queryStr, err := OptionalParam[string](args, "query") + owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - pagination, err := extractPaginationOptionsFromArgs(args) + ownerType, err := OptionalParam[string](args, "owner_type") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } @@ -113,1225 +226,381 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { 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), - } + switch method { + case projectsMethodListProjects: + return listProjects(ctx, client, args, owner, ownerType) + default: + // All other methods require project_number and ownerType detection + 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 + } + } - r, err := json.Marshal(response) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + switch method { + case projectsMethodListProjectFields: + return listProjectFields(ctx, client, args, owner, ownerType) + case projectsMethodListProjectItems: + return listProjectItems(ctx, client, args, owner, ownerType) + case projectsMethodListProjectStatusUpdates: + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } } - - return utils.NewToolResultText(string(r)), nil, nil }, ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects return tool } -func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { +// ProjectsGet returns the tool and handler for getting GitHub Projects resources. +func ProjectsGet(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"), + Name: "projects_get", + Description: t("TOOL_PROJECTS_GET_DESCRIPTION", `Get details about specific GitHub Projects resources. +Use this tool to get details about individual projects, project fields, and project items by their unique IDs. +`), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"), + Title: t("TOOL_PROJECTS_GET_USER_TITLE", "Get details of GitHub Projects resources"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ - "project_number": { - Type: "number", - Description: "The project's number", + "method": { + Type: "string", + Description: "The method to execute", + Enum: []any{ + projectsMethodGetProject, + projectsMethodGetProjectField, + projectsMethodGetProjectItem, + projectsMethodGetProjectStatusUpdate, + }, }, "owner_type": { Type: "string", - Description: "Owner type", + Description: "Owner type (user or org). If not provided, will be automatically detected.", 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.", + Description: "The owner (user or organization login). The name is not case sensitive.", + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "field_id": { + Type: "number", + Description: "The field's ID. Required for 'get_project_field' method.", + }, + "item_id": { + Type: "number", + Description: "The item's ID. Required for 'get_project_item' method.", + }, + "fields": { + Type: "array", + Description: "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included. Only used for 'get_project_item' method.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "status_update_id": { + Type: "string", + Description: "The node ID of the project status update. Required for 'get_project_status_update' method.", }, }, - Required: []string{"project_number", "owner_type", "owner"}, + Required: []string{"method"}, }, }, []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") + method, err := RequiredParam[string](args, "method") if err != nil { 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 + } + return getProjectStatusUpdate(ctx, gqlClient, statusUpdateID) + } + owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - ownerType, err := RequiredParam[string](args, "owner_type") + ownerType, err := OptionalParam[string](args, "owner_type") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - client, err := deps.GetClient(ctx) + projectNumber, err := RequiredInt(args, "project_number") 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) - } + client, err := deps.GetClient(ctx) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project", - resp, - err, - ), nil, nil + return utils.NewToolResultError(err.Error()), nil, nil } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) + // Detect owner type if not provided + if ownerType == "" { + ownerType, err = detectOwnerType(ctx, client, owner, projectNumber) if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) + return utils.NewToolResultError(err.Error()), nil, nil } - 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) + switch method { + case projectsMethodGetProject: + return getProject(ctx, client, owner, ownerType, projectNumber) + case projectsMethodGetProjectField: + fieldID, err := RequiredBigInt(args, "field_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID) + case projectsMethodGetProjectItem: + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + fields, err := OptionalBigIntArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - - return utils.NewToolResultText(string(r)), nil, nil }, ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects return tool } -func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerTool { +// ProjectsWrite returns the tool and handler for modifying GitHub Projects resources. +func ProjectsWrite(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"), + Name: "projects_write", + 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_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), - ReadOnlyHint: true, + Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Manage GitHub Projects"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The method to execute", + Enum: []any{ + projectsMethodAddProjectItem, + projectsMethodUpdateProjectItem, + projectsMethodDeleteProjectItem, + projectsMethodCreateProjectStatusUpdate, + projectsMethodCreateProject, + projectsMethodCreateIterationField, + }, + }, "owner_type": { Type: "string", - Description: "Owner type", + 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": { Type: "string", - Description: "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", + Description: "The project owner (user or organization login). The name is not case sensitive.", }, "project_number": { Type: "number", - Description: "The project's number.", + Description: "The project's number. Required for all methods except 'create_project'.", }, - "per_page": { + "title": { + Type: "string", + Description: "The project title. Required for 'create_project' method.", + }, + "item_id": { Type: "number", - Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), + Description: "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods.", }, - "after": { + "item_type": { Type: "string", - Description: "Forward pagination cursor from previous pageInfo.nextCursor.", + Description: "The item's type, either issue or pull_request. Required for 'add_project_item' method.", + Enum: []any{"issue", "pull_request"}, }, - "before": { + "item_owner": { Type: "string", - Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", + Description: "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method.", + }, + "item_repo": { + Type: "string", + Description: "The name of the repository containing the issue or pull request. Required for 'add_project_item' method.", + }, + "issue_number": { + Type: "number", + Description: "The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number.", + }, + "pull_request_number": { + Type: "number", + Description: "The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number.", + }, + "updated_field": { + Type: "object", + Description: "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.", + }, + "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{"owner_type", "owner", "project_number"}, + Required: []string{"method", "owner"}, }, }, - []scopes.Scope{scopes.ReadProject}, + []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") + method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - projectNumber, err := RequiredInt(args, "project_number") + owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - pagination, err := extractPaginationOptionsFromArgs(args) + ownerType, err := OptionalParam[string](args, "owner_type") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - client, err := deps.GetClient(ctx) + gqlClient, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - var resp *github.Response - var projectFields []*github.ProjectV2Field - - opts := &github.ListProjectsOptions{ - ListProjectsPaginationOptions: pagination, + // create_project does not require project_number or a REST client + if method == projectsMethodCreateProject { + return createProject(ctx, gqlClient, owner, ownerType, args) } - if ownerType == "org" { - projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts) - } else { - projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts) + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil } + client, err := deps.GetClient(ctx) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list project fields", - resp, - err, - ), nil, nil + return utils.NewToolResultError(err.Error()), nil, nil } - defer func() { _ = resp.Body.Close() }() - response := map[string]any{ - "fields": projectFields, - "pageInfo": buildPageInfo(resp), + // Detect owner type if not provided + if ownerType == "" { + ownerType, err = detectOwnerType(ctx, client, owner, projectNumber) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } } - r, err := json.Marshal(response) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } + switch method { + case projectsMethodAddProjectItem: + itemType, err := RequiredParam[string](args, "item_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + itemOwner, err := RequiredParam[string](args, "item_owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + itemRepo, err := RequiredParam[string](args, "item_repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - return tool -} + var itemNumber int + switch itemType { + case "issue": + itemNumber, err = RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError("issue_number is required when item_type is 'issue'"), nil, nil + } + case "pull_request": + itemNumber, err = RequiredInt(args, "pull_request_number") + if err != nil { + return utils.NewToolResultError("pull_request_number is required when item_type is 'pull_request'"), nil, nil + } + default: + return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil + } -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.FeatureFlagDisable = FeatureFlagConsolidatedProjects - 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.FeatureFlagDisable = FeatureFlagConsolidatedProjects - 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.FeatureFlagDisable = FeatureFlagConsolidatedProjects - 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() }() - - 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) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - return tool -} - -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 - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - var resp *github.Response - var updatedItem *github.ProjectV2Item - - if ownerType == "org" { - updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload) - } else { - updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectUpdateFailedError, - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectUpdateFailedError, resp, body), nil, nil - } - r, err := json.Marshal(updatedItem) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - return tool -} - -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) { - - 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 - } - - 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) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectDeleteFailedError, - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusNoContent { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectDeleteFailedError, resp, body), nil, nil - } - return utils.NewToolResultText("project item successfully deleted"), nil, nil - }, - ) - tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - return tool -} - -// ProjectsList returns the tool and handler for listing GitHub Projects resources. -func ProjectsList(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "projects_list", - Description: t("TOOL_PROJECTS_LIST_DESCRIPTION", - `Tools for listing GitHub Projects resources. -Use this tool to list projects for a user or organization, or list project fields and items for a specific project. -`), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_PROJECTS_LIST_USER_TITLE", "List GitHub Projects resources"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "method": { - Type: "string", - Description: "The action to perform", - Enum: []any{ - projectsMethodListProjects, - projectsMethodListProjectFields, - projectsMethodListProjectItems, - }, - }, - "owner_type": { - Type: "string", - Description: "Owner type", - 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. Required for 'list_project_fields' and 'list_project_items' methods.", - }, - "query": { - Type: "string", - Description: `Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax.`, - }, - "fields": { - Type: "array", - Description: "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method.", - Items: &jsonschema.Schema{ - Type: "string", - }, - }, - "per_page": { - Type: "number", - Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), - }, - "after": { - Type: "string", - Description: "Forward pagination cursor from previous pageInfo.nextCursor.", - }, - "before": { - Type: "string", - Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", - }, - }, - Required: []string{"method", "owner_type", "owner"}, - }, - }, - []scopes.Scope{scopes.ReadProject}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - ownerType, err := 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 - } - - switch method { - case projectsMethodListProjects: - return listProjects(ctx, client, args, owner, ownerType) - case projectsMethodListProjectFields: - return listProjectFields(ctx, client, args, owner, ownerType) - case projectsMethodListProjectItems: - return listProjectItems(ctx, client, args, owner, ownerType) - default: - return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil - } - }, - ) - tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects - return tool -} - -// ProjectsGet returns the tool and handler for getting GitHub Projects resources. -func ProjectsGet(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "projects_get", - Description: t("TOOL_PROJECTS_GET_DESCRIPTION", `Get details about specific GitHub Projects resources. -Use this tool to get details about individual projects, project fields, and project items by their unique IDs. -`), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_PROJECTS_GET_USER_TITLE", "Get details of GitHub Projects resources"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "method": { - Type: "string", - Description: "The method to execute", - Enum: []any{ - projectsMethodGetProject, - projectsMethodGetProjectField, - projectsMethodGetProjectItem, - }, - }, - "owner_type": { - Type: "string", - Description: "Owner type", - 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 for 'get_project_field' method.", - }, - "item_id": { - Type: "number", - Description: "The item's ID. Required for 'get_project_item' method.", - }, - "fields": { - Type: "array", - Description: "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included. Only used for 'get_project_item' method.", - Items: &jsonschema.Schema{ - Type: "string", - }, - }, - }, - Required: []string{"method", "owner_type", "owner", "project_number"}, - }, - }, - []scopes.Scope{scopes.ReadProject}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - ownerType, err := 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 - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - switch method { - case projectsMethodGetProject: - return getProject(ctx, client, owner, ownerType, projectNumber) - case projectsMethodGetProjectField: - fieldID, err := RequiredBigInt(args, "field_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - return getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID) - case projectsMethodGetProjectItem: - itemID, err := RequiredBigInt(args, "item_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - fields, err := OptionalBigIntArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - return getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) - default: - return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil - } - }, - ) - tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects - return tool -} - -// ProjectsWrite returns the tool and handler for modifying GitHub Projects resources. -func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "projects_write", - Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Add, update, or delete project items in a GitHub Project."), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Modify GitHub Project items"), - ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "method": { - Type: "string", - Description: "The method to execute", - Enum: []any{ - projectsMethodAddProjectItem, - projectsMethodUpdateProjectItem, - projectsMethodDeleteProjectItem, - }, - }, - "owner_type": { - Type: "string", - Description: "Owner type", - 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 project item ID. Required for 'update_project_item' and 'delete_project_item' methods. For add_project_item, this is the numeric ID of the issue or pull request to add.", - }, - "item_type": { - Type: "string", - Description: "The item's type, either issue or pull_request. Required for 'add_project_item' method.", - Enum: []any{"issue", "pull_request"}, - }, - "updated_field": { - Type: "object", - Description: "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.", - }, - }, - Required: []string{"method", "owner_type", "owner", "project_number"}, - }, - }, - []scopes.Scope{scopes.Project}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - ownerType, err := 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 - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - switch method { - case projectsMethodAddProjectItem: - 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 - } - return addProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, itemType) + return addProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, itemOwner, itemRepo, itemNumber, itemType) case projectsMethodUpdateProjectItem: itemID, err := RequiredBigInt(args, "item_id") if err != nil { @@ -1352,12 +621,31 @@ 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.FeatureFlagEnable = FeatureFlagConsolidatedProjects return tool } @@ -1376,47 +664,112 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an var resp *github.Response var projects []*github.ProjectV2 - var queryPtr *string - - if queryStr != "" { - queryPtr = &queryStr - } minimalProjects := []MinimalProject{} opts := &github.ListProjectsOptions{ ListProjectsPaginationOptions: pagination, - Query: queryPtr, + Query: queryStr, } - if ownerType == "org" { + // If owner_type not provided, fetch from both user and org + switch ownerType { + case "": + return listProjectsFromBothOwnerTypes(ctx, client, owner, opts) + case "org": projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts) - } else { + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list projects", + resp, + err, + ), nil, nil + } + default: projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list projects", + resp, + err, + ), nil, nil + } } - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list projects", - resp, - err, - ), nil, nil + // For specified owner_type, process normally + if ownerType != "" { + defer func() { _ = resp.Body.Close() }() + + for _, project := range projects { + mp := convertToMinimalProject(project) + mp.OwnerType = ownerType + minimalProjects = append(minimalProjects, *mp) + } + + response := map[string]any{ + "projects": minimalProjects, + "pageInfo": buildPageInfo(resp), + } + + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil + } + + return nil, nil, fmt.Errorf("unexpected state in listProjects") +} + +// listProjectsFromBothOwnerTypes fetches projects from both user and org endpoints +// when owner_type is not specified, combining the results with owner_type labels. +func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, owner string, opts *github.ListProjectsOptions) (*mcp.CallToolResult, any, error) { + var minimalProjects []MinimalProject + var resp *github.Response + + // Fetch user projects + userProjects, userResp, userErr := client.Projects.ListUserProjects(ctx, owner, opts) + if userErr == nil && userResp.StatusCode == http.StatusOK { + for _, project := range userProjects { + mp := convertToMinimalProject(project) + mp.OwnerType = "user" + minimalProjects = append(minimalProjects, *mp) + } + _ = userResp.Body.Close() } - defer func() { _ = resp.Body.Close() }() - for _, project := range projects { - minimalProjects = append(minimalProjects, *convertToMinimalProject(project)) + // Fetch org projects + orgProjects, orgResp, orgErr := client.Projects.ListOrganizationProjects(ctx, owner, opts) + if orgErr == nil && orgResp.StatusCode == http.StatusOK { + for _, project := range orgProjects { + mp := convertToMinimalProject(project) + mp.OwnerType = "org" + minimalProjects = append(minimalProjects, *mp) + } + resp = orgResp // Use org response for pagination info + } else if userResp != nil { + resp = userResp // Fallback to user response + } + + // If both failed, return error + if (userErr != nil || userResp == nil || userResp.StatusCode != http.StatusOK) && + (orgErr != nil || orgResp == nil || orgResp.StatusCode != http.StatusOK) { + return utils.NewToolResultError(fmt.Sprintf("failed to list projects for owner '%s': not found as user or organization", owner)), nil, nil } response := map[string]any{ "projects": minimalProjects, - "pageInfo": buildPageInfo(resp), + "note": "Results include both user and org projects. Each project includes 'owner_type' field. Pagination is limited when owner_type is not specified - specify 'owner_type' for full pagination support.", + } + if resp != nil { + response["pageInfo"] = buildPageInfo(resp) + defer func() { _ = resp.Body.Close() }() } r, err := json.Marshal(response) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil } @@ -1489,17 +842,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, }, } @@ -1518,8 +866,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), } @@ -1608,36 +961,198 @@ func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType var opts *github.GetProjectItemOptions var err error - if len(fields) > 0 { - opts = &github.GetProjectItemOptions{ - Fields: fields, - } + if len(fields) > 0 { + opts = &github.GetProjectItemOptions{ + Fields: fields, + } + } + + if ownerType == "org" { + projectItem, resp, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, itemID, opts) + } else { + projectItem, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, itemID, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project item", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project item", resp, body), nil, nil + } + + r, err := json.Marshal(convertToMinimalProjectItem(projectItem)) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func updateProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fieldValue map[string]any) (*mcp.CallToolResult, any, error) { + updatePayload, err := buildUpdateProjectItem(fieldValue) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var updatedItem *github.ProjectV2Item + + if ownerType == "org" { + updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload) + } else { + updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectUpdateFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectUpdateFailedError, resp, body), nil, nil + } + r, err := json.Marshal(convertToMinimalProjectItem(updatedItem)) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var err error + + if ownerType == "org" { + resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID) + } else { + resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectDeleteFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectDeleteFailedError, resp, body), nil, nil + } + return utils.NewToolResultText("project item successfully deleted"), nil, nil +} + +// 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" { + return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil + } + + // Resolve the item number to a node ID + var nodeID githubv4.ID + var err error + if itemType == "issue" { + nodeID, err = resolveIssueNodeID(ctx, gqlClient, itemOwner, itemRepo, itemNumber) + } else { + nodeID, err = resolvePullRequestNodeID(ctx, gqlClient, itemOwner, itemRepo, itemNumber) + } + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to resolve %s: %v", itemType, err)), nil, nil + } + + // Use GraphQL to add the item to the project + var mutation struct { + AddProjectV2ItemByID struct { + Item struct { + ID githubv4.ID + } + } `graphql:"addProjectV2ItemById(input: $input)"` } - 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) + // 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 + input := githubv4.AddProjectV2ItemByIdInput{ + ProjectID: projectID, + ContentID: nodeID, + } + + err = gqlClient.Mutate(ctx, &mutation, input, nil) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project item", - resp, - err, - ), nil, nil + return utils.NewToolResultError(fmt.Sprintf(ProjectAddFailedError+": %v", err)), nil, nil } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project item", resp, body), nil, nil + result := map[string]any{ + "id": mutation.AddProjectV2ItemByID.Item.ID, + "message": fmt.Sprintf("Successfully added %s %s/%s#%d to project %s/%d", itemType, itemOwner, itemRepo, itemNumber, owner, projectNumber), } - r, err := json.Marshal(projectItem) + r, err := json.Marshal(result) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -1645,43 +1160,78 @@ func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType return utils.NewToolResultText(string(r)), nil, nil } -func addProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, itemType string) (*mcp.CallToolResult, any, error) { - if itemType != "issue" && itemType != "pull_request" { - return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil +// 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 +} - newItem := &github.AddProjectItemOptions{ - ID: itemID, - Type: toNewProjectType(itemType), +// 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 + } } - var resp *github.Response - var addedItem *github.ProjectV2Item - var err error + // 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 + } - if ownerType == "org" { - addedItem, resp, err = client.Projects.AddOrganizationProjectItem(ctx, owner, projectNumber, newItem) - } else { - addedItem, resp, err = client.Projects.AddUserProjectItem(ctx, owner, projectNumber, newItem) + // Build mutation input + input := CreateProjectV2StatusUpdateInput{ + ProjectID: projectID, } - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectAddFailedError, - resp, - err, - ), nil, nil + 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 } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusCreated { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectAddFailedError, resp, body), nil, nil + // 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 } - r, err := json.Marshal(addedItem) + + // 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) } @@ -1689,72 +1239,107 @@ func addProjectItem(ctx context.Context, client *github.Client, owner, ownerType return utils.NewToolResultText(string(r)), nil, nil } -func updateProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fieldValue map[string]any) (*mcp.CallToolResult, any, error) { - updatePayload, err := buildUpdateProjectItem(fieldValue) +// 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, any, error) { + if ownerType != "user" && ownerType != "org" { + return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil + } + + projectNumber, err := RequiredInt(args, "project_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - var resp *github.Response - var updatedItem *github.ProjectV2Item + perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) + if err != nil { + return utils.NewToolResultError(err.Error()), 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()), 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 if ownerType == "org" { - updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload) + var q statusUpdatesOrgQuery + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), nil, nil + } + nodes = q.Organization.ProjectV2.StatusUpdates.Nodes + pi = q.Organization.ProjectV2.StatusUpdates.PageInfo } else { - updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload) + var q statusUpdatesUserQuery + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), nil, nil + } + nodes = q.User.ProjectV2.StatusUpdates.Nodes + pi = q.User.ProjectV2.StatusUpdates.PageInfo } - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectUpdateFailedError, - resp, - err, - ), nil, nil + updates := make([]MinimalProjectStatusUpdate, 0, len(nodes)) + for _, n := range nodes { + updates = append(updates, convertToMinimalStatusUpdate(n)) } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectUpdateFailedError, resp, body), nil, nil + 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(updatedItem) + + r, err := json.Marshal(response) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil } -func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64) (*mcp.CallToolResult, any, error) { - var resp *github.Response - var err error +// getProjectStatusUpdate fetches a single status update by its node ID via GraphQL. +func getProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, statusUpdateID string) (*mcp.CallToolResult, any, error) { + var q statusUpdateNodeQuery + vars := map[string]any{ + "id": githubv4.ID(statusUpdateID), + } - if ownerType == "org" { - resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID) - } else { - resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID) + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateGetFailedError, err)), nil, nil } - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectDeleteFailedError, - resp, - err, - ), 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)), nil, nil } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusNoContent { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectDeleteFailedError, resp, body), nil, nil + update := convertToMinimalStatusUpdate(q.Node.StatusUpdate) + + r, err := json.Marshal(update) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText("project item successfully deleted"), nil, nil + return utils.NewToolResultText(string(r)), nil, nil } type pageInfo struct { @@ -1764,17 +1349,6 @@ type pageInfo struct { PrevCursor string `json:"prevCursor,omitempty"` } -func toNewProjectType(projType string) string { - switch strings.ToLower(projType) { - case "issue": - return "Issue" - case "pull_request": - return "PullRequest" - default: - return "" - } -} - // validateAndConvertToInt64 ensures the value is a number and converts it to int64. func validateAndConvertToInt64(value any) (int64, error) { switch v := value.(type) { @@ -1854,17 +1428,343 @@ func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsP } opts := github.ListProjectsPaginationOptions{ - PerPage: &perPage, + PerPage: perPage, + After: after, + Before: before, + } + + return opts, nil +} + +// resolveIssueNodeID resolves an issue number to its GraphQL node ID +func resolveIssueNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int) (githubv4.ID, error) { + var query struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` } - // Only set After/Before if they have non-empty values - if after != "" { - opts.After = &after + variables := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "issueNumber": githubv4.Int(int32(issueNumber)), //nolint:gosec // Issue numbers are small integers } - if before != "" { - opts.Before = &before + err := gqlClient.Query(ctx, &query, variables) + if err != nil { + return "", fmt.Errorf("failed to resolve issue %s/%s#%d: %w", owner, repo, issueNumber, err) } - return opts, nil + return query.Repository.Issue.ID, nil +} + +// resolvePullRequestNodeID resolves a pull request number to its GraphQL node ID +func resolvePullRequestNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, prNumber int) (githubv4.ID, error) { + var query struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + } `graphql:"pullRequest(number: $prNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + variables := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "prNumber": githubv4.Int(int32(prNumber)), //nolint:gosec // PR numbers are small integers + } + + err := gqlClient.Query(ctx, &query, variables) + if err != nil { + return "", fmt.Errorf("failed to resolve pull request %s/%s#%d: %w", owner, repo, prNumber, err) + } + + return query.Repository.PullRequest.ID, nil +} + +// createProject handles the create_project method for ProjectsWrite. +func createProject(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, args map[string]any) (*mcp.CallToolResult, any, error) { + if ownerType == "" { + return utils.NewToolResultError("owner_type is required for create_project"), nil, nil + } + if ownerType != "user" && ownerType != "org" { + return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil + } + + title, err := RequiredParam[string](args, "title") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerID, err := getOwnerNodeID(ctx, gqlClient, owner, ownerType) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get owner ID: %v", err)), nil, nil + } + + var mutation struct { + CreateProjectV2 struct { + ProjectV2 struct { + ID string + Number int + Title string + URL string + } + } `graphql:"createProjectV2(input: $input)"` + } + + input := githubv4.CreateProjectV2Input{ + OwnerID: githubv4.ID(ownerID), + Title: githubv4.String(title), + } + + err = gqlClient.Mutate(ctx, &mutation, input, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create project: %v", err)), nil, nil + } + + result := struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + }{ + ID: mutation.CreateProjectV2.ProjectV2.ID, + Number: mutation.CreateProjectV2.ProjectV2.Number, + Title: mutation.CreateProjectV2.ProjectV2.Title, + URL: mutation.CreateProjectV2.ProjectV2.URL, + } + + return MarshalledTextResult(result), nil, nil +} + +// createIterationField handles the create_iteration_field method for ProjectsWrite. +// +// GitHub's GraphQL API requires two mutations to fully configure an iteration field: +// 1. createProjectV2Field creates the field with DataType=ITERATION (no schedule yet). +// 2. updateProjectV2Field sets the start date, duration, and optional named iterations. +// +// If step 2 fails, the field already exists with default settings and can be reconfigured +// by calling this method again (the create will fail with a duplicate-name error, which +// surfaces clearly) or by deleting the field via the GitHub UI. +func createIterationField(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, args map[string]any) (*mcp.CallToolResult, any, error) { + fieldName, err := RequiredParam[string](args, "field_name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + duration, err := RequiredInt(args, "iteration_duration") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + startDateStr, err := RequiredParam[string](args, "start_date") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil + } + + // Step 1: Create the iteration field. + var createMutation struct { + CreateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"createProjectV2Field(input: $input)"` + } + + createInput := githubv4.CreateProjectV2FieldInput{ + ProjectID: githubv4.ID(projectID), + DataType: githubv4.ProjectV2CustomFieldType("ITERATION"), + Name: githubv4.String(fieldName), + } + + err = gqlClient.Mutate(ctx, &createMutation, createInput, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create iteration field: %v", err)), nil, nil + } + + fieldID := createMutation.CreateProjectV2Field.ProjectV2Field.ProjectV2IterationField.ID + + // Step 2: Configure the iteration field with start date and duration. + var updateMutation struct { + UpdateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + Configuration struct { + Iterations []struct { + ID string + Title string + StartDate string + Duration int + } + } + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"updateProjectV2Field(input: $input)"` + } + + parsedStartDate, err := time.Parse("2006-01-02", startDateStr) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to parse start_date %s: %v", startDateStr, err)), nil, nil + } + + // GitHub's ProjectV2IterationFieldConfigurationInput requires `iterations` as a + // non-null array, so we always send at least an empty slice. When omitted, GitHub + // generates a default set of iterations from start_date and duration. + iterationsInput := []ProjectV2IterationFieldIterationInput{} + + if rawIterations, ok := args["iterations"].([]any); ok && len(rawIterations) > 0 { + for i, item := range rawIterations { + iterMap, ok := item.(map[string]any) + if !ok { + return utils.NewToolResultError(fmt.Sprintf("iterations[%d] must be an object", i)), nil, nil + } + iterTitle, ok := iterMap["title"].(string) + if !ok || iterTitle == "" { + return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: title is required and must be a non-empty string", i)), nil, nil + } + iterStartDate, ok := iterMap["start_date"].(string) + if !ok || iterStartDate == "" { + return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: start_date is required and must be a non-empty string", i)), nil, nil + } + iterDuration, ok := iterMap["duration"].(float64) + if !ok || iterDuration <= 0 { + return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: duration is required and must be a positive number", i)), nil, nil + } + + parsedIterStartDate, err := time.Parse("2006-01-02", iterStartDate) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: failed to parse start_date %q: %v", i, iterStartDate, err)), nil, nil + } + + iterationsInput = append(iterationsInput, ProjectV2IterationFieldIterationInput{ + Title: githubv4.String(iterTitle), + StartDate: githubv4.Date{Time: parsedIterStartDate}, + Duration: githubv4.Int(int32(iterDuration)), //nolint:gosec // Iteration durations are small day counts + }) + } + } + + configInput := ProjectV2IterationFieldConfigurationInput{ + Duration: githubv4.Int(int32(duration)), //nolint:gosec // Iteration durations are small day counts + StartDate: githubv4.Date{Time: parsedStartDate}, + Iterations: iterationsInput, + } + + updateInput := UpdateProjectV2FieldInput{ + FieldID: githubv4.ID(fieldID), + IterationConfiguration: &configInput, + } + + err = gqlClient.Mutate(ctx, &updateMutation, updateInput, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to update iteration configuration: %v", err)), nil, nil + } + + field := updateMutation.UpdateProjectV2Field.ProjectV2Field.ProjectV2IterationField + iterResults := make([]map[string]any, 0, len(field.Configuration.Iterations)) + for _, iter := range field.Configuration.Iterations { + iterResults = append(iterResults, map[string]any{ + "id": iter.ID, + "title": iter.Title, + "start_date": iter.StartDate, + "duration": iter.Duration, + }) + } + + result := map[string]any{ + "id": field.ID, + "name": field.Name, + "configuration": map[string]any{ + "iterations": iterResults, + }, + } + + return MarshalledTextResult(result), nil, nil +} + +// getOwnerNodeID resolves a GitHub user or organization login to its GraphQL node ID. +func getOwnerNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string) (string, error) { + if ownerType == "org" { + var query struct { + Organization struct { + ID string + } `graphql:"organization(login: $login)"` + } + variables := map[string]any{ + "login": githubv4.String(owner), + } + err := gqlClient.Query(ctx, &query, variables) + return query.Organization.ID, err + } + + var query struct { + User struct { + ID string + } `graphql:"user(login: $login)"` + } + variables := map[string]any{ + "login": githubv4.String(owner), + } + err := gqlClient.Query(ctx, &query, variables) + return query.User.ID, err +} + +// UpdateProjectV2FieldInput is the GraphQL input for the updateProjectV2Field mutation. +// These types are defined locally because the pinned shurcooL/githubv4 release +// (v0.0.0-20240727222349) does not yet expose them. Upstream master now generates +// equivalent types, so this block can be removed when the dependency is next bumped. +type UpdateProjectV2FieldInput struct { + FieldID githubv4.ID `json:"fieldId"` + IterationConfiguration *ProjectV2IterationFieldConfigurationInput `json:"iterationConfiguration,omitempty"` +} + +// ProjectV2IterationFieldConfigurationInput is the GraphQL input for configuring an iteration field. +// GitHub's schema marks iterations as a required non-null list, so the field is not omitempty. +type ProjectV2IterationFieldConfigurationInput struct { + Duration githubv4.Int `json:"duration"` + StartDate githubv4.Date `json:"startDate"` + Iterations []ProjectV2IterationFieldIterationInput `json:"iterations"` +} + +// ProjectV2IterationFieldIterationInput is the GraphQL input for a single iteration definition. +type ProjectV2IterationFieldIterationInput struct { + StartDate githubv4.Date `json:"startDate"` + Duration githubv4.Int `json:"duration"` + Title githubv4.String `json:"title"` +} + +// detectOwnerType attempts to detect the owner type by trying both user and org +// Returns the detected type ("user" or "org") and any error encountered +func detectOwnerType(ctx context.Context, client *github.Client, owner string, projectNumber int) (string, error) { + // Try user first (more common for personal projects) + _, resp, err := client.Projects.GetUserProject(ctx, owner, projectNumber) + if err == nil && resp.StatusCode == http.StatusOK { + _ = resp.Body.Close() + return "user", nil + } + if resp != nil { + _ = resp.Body.Close() + } + + // If not found (404) or other error, try org + _, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) + if err == nil && resp.StatusCode == http.StatusOK { + _ = resp.Body.Close() + return "org", nil + } + if resp != nil { + _ = resp.Body.Close() + } + + return "", fmt.Errorf("could not determine owner type for %s with project %d: owner is neither a user nor an org with this project", owner, projectNumber) } diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 9819e7d7e0..a9787298af 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -6,1530 +6,15 @@ import ( "net/http" "testing" + "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - gh "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" + "github.com/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) { @@ -1546,7 +31,7 @@ func Test_ProjectsList(t *testing.T) { assert.Contains(t, inputSchema.Properties, "project_number") assert.Contains(t, inputSchema.Properties, "query") assert.Contains(t, inputSchema.Properties, "fields") - assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner_type", "owner"}) + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner"}) } func Test_ProjectsList_ListProjects(t *testing.T) { @@ -1614,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, } @@ -1637,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)) }) @@ -1654,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, } @@ -1674,14 +159,14 @@ 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, } @@ -1700,17 +185,159 @@ func Test_ProjectsList_ListProjectFields(t *testing.T) { }) } +func verbosePullRequestProjectItemFixture() map[string]any { + return map[string]any{ + "id": 1001, + "node_id": "PVTI_1", + "content_type": "PullRequest", + "item_url": "https://api.github.com/projectsV2/items/1001", + "project_url": "https://api.github.com/orgs/octo-org/projectsV2/1", + "creator": map[string]any{ + "login": "creator", + "id": 999, + "followers_url": "https://api.github.com/users/creator/followers", + }, + "content": map[string]any{ + "id": 2002, + "node_id": "PR_1", + "number": 42, + "title": "Reduce project item output", + "body": "Long pull request body that should not be returned from project item tools.", + "state": "closed", + "html_url": "https://github.com/cli/cli/pull/42", + "url": "https://api.github.com/repos/cli/cli/pulls/42", + "diff_url": "https://github.com/cli/cli/pull/42.diff", + "patch_url": "https://github.com/cli/cli/pull/42.patch", + "draft": false, + "merged": true, + "created_at": "2026-05-07T18:41:21Z", + "updated_at": "2026-05-07T21:21:57Z", + "closed_at": "2026-05-07T21:21:55Z", + "merged_at": "2026-05-07T21:21:55Z", + "user": map[string]any{ + "login": "octocat", + "id": 123, + "followers_url": "https://api.github.com/users/octocat/followers", + }, + "assignees": []map[string]any{ + { + "login": "hubot", + "events_url": "https://api.github.com/users/hubot/events{/privacy}", + }, + }, + "labels": []map[string]any{ + { + "name": "bug", + "url": "https://api.github.com/repos/cli/cli/labels/bug", + }, + }, + "milestone": map[string]any{ + "title": "v1.0", + "description": "Verbose milestone description", + }, + "head": map[string]any{ + "ref": "feature", + "repo": map[string]any{ + "full_name": "fork/cli", + "archive_url": "https://api.github.com/repos/fork/cli/{archive_format}{/ref}", + }, + }, + "base": map[string]any{ + "ref": "trunk", + "repo": map[string]any{ + "full_name": "cli/cli", + "archive_url": "https://api.github.com/repos/cli/cli/{archive_format}{/ref}", + }, + }, + "_links": map[string]any{ + "self": map[string]any{ + "href": "https://api.github.com/repos/cli/cli/pulls/42", + }, + }, + "statuses_url": "https://api.github.com/repos/cli/cli/statuses/abc123", + }, + "fields": []map[string]any{ + { + "id": 301, + "name": "Status", + "data_type": "single_select", + "value": map[string]any{ + "id": "opt1", + "name": "Done", + "color": "GREEN", + "description": "Verbose option description", + }, + }, + }, + "created_at": "2026-05-28T07:39:37Z", + "updated_at": "2026-05-28T07:40:15Z", + } +} + +func assertMinimalPullRequestProjectItem(t *testing.T, rawJSON string, item map[string]any) { + t.Helper() + + assert.Equal(t, float64(1001), item["id"]) + assert.Equal(t, "PVTI_1", item["node_id"]) + assert.Equal(t, "PullRequest", item["content_type"]) + assert.Equal(t, "creator", item["creator"]) + assert.Equal(t, "2026-05-28T07:39:37Z", item["created_at"]) + assert.Equal(t, "2026-05-28T07:40:15Z", item["updated_at"]) + + content, ok := item["content"].(map[string]any) + require.True(t, ok) + assert.Equal(t, float64(42), content["number"]) + assert.Equal(t, "Reduce project item output", content["title"]) + assert.Equal(t, "closed", content["state"]) + assert.Equal(t, "https://github.com/cli/cli/pull/42", content["html_url"]) + assert.Equal(t, "cli/cli", content["repository"]) + assert.Equal(t, "octocat", content["author"]) + assert.Equal(t, true, content["merged"]) + assert.Equal(t, "2026-05-07T18:41:21Z", content["created_at"]) + assert.Equal(t, "2026-05-07T21:21:57Z", content["updated_at"]) + assert.Equal(t, "2026-05-07T21:21:55Z", content["closed_at"]) + assert.Equal(t, "2026-05-07T21:21:55Z", content["merged_at"]) + assert.Equal(t, []any{"hubot"}, content["assignees"]) + assert.Equal(t, []any{"bug"}, content["labels"]) + assert.Equal(t, "v1.0", content["milestone"]) + + fields, ok := item["fields"].([]any) + require.True(t, ok) + require.Len(t, fields, 1) + field, ok := fields[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, float64(301), field["id"]) + assert.Equal(t, "Status", field["name"]) + assert.Equal(t, "single_select", field["data_type"]) + value, ok := field["value"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "opt1", value["id"]) + assert.Equal(t, "Done", value["name"]) + assert.Equal(t, "GREEN", value["color"]) + + assert.NotContains(t, rawJSON, `"body"`) + assert.NotContains(t, rawJSON, `"archive_url"`) + assert.NotContains(t, rawJSON, `"followers_url"`) + assert.NotContains(t, rawJSON, `"events_url"`) + assert.NotContains(t, rawJSON, `"_links"`) + assert.NotContains(t, rawJSON, `"head"`) + assert.NotContains(t, rawJSON, `"base"`) + assert.NotContains(t, rawJSON, `"url":`) + assert.NotContains(t, rawJSON, `"statuses_url"`) + assert.NotContains(t, rawJSON, `"diff_url"`) +} + func Test_ProjectsList_ListProjectItems(t *testing.T) { toolDef := ProjectsList(translations.NullTranslationHelper) - items := []map[string]any{{"id": 1001, "archived_at": nil, "content": map[string]any{"title": "Issue 1"}}} + items := []map[string]any{verbosePullRequestProjectItemFixture()} t.Run("success organization", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -1730,9 +357,12 @@ func Test_ProjectsList_ListProjectItems(t *testing.T) { var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) - itemsList, ok := response["items"].([]interface{}) + 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) }) } @@ -1750,7 +380,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_type", "owner", "project_number"}) + assert.ElementsMatch(t, inputSchema.Required, []string{"method"}) } func Test_ProjectsGet_GetProject(t *testing.T) { @@ -1763,7 +393,7 @@ func Test_ProjectsGet_GetProject(t *testing.T) { GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -1788,7 +418,7 @@ func Test_ProjectsGet_GetProject(t *testing.T) { t.Run("unknown method", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -1818,7 +448,7 @@ func Test_ProjectsGet_GetProjectField(t *testing.T) { GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusOK, field), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -1844,7 +474,7 @@ func Test_ProjectsGet_GetProjectField(t *testing.T) { t.Run("missing field_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -1867,14 +497,14 @@ func Test_ProjectsGet_GetProjectField(t *testing.T) { func Test_ProjectsGet_GetProjectItem(t *testing.T) { toolDef := ProjectsGet(translations.NullTranslationHelper) - item := map[string]any{"id": 1001, "archived_at": nil, "content": map[string]any{"title": "Issue 1"}} + item := verbosePullRequestProjectItemFixture() t.Run("success organization", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, item), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -1895,12 +525,12 @@ func Test_ProjectsGet_GetProjectItem(t *testing.T) { var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) - assert.NotNil(t, response["id"]) + assertMinimalPullRequestProjectItem(t, textContent.Text, response) }) t.Run("missing item_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -1934,8 +564,12 @@ func Test_ProjectsWrite(t *testing.T) { assert.Contains(t, inputSchema.Properties, "project_number") assert.Contains(t, inputSchema.Properties, "item_id") assert.Contains(t, inputSchema.Properties, "item_type") + assert.Contains(t, inputSchema.Properties, "item_owner") + assert.Contains(t, inputSchema.Properties, "item_repo") + assert.Contains(t, inputSchema.Properties, "issue_number") + assert.Contains(t, inputSchema.Properties, "pull_request_number") assert.Contains(t, inputSchema.Properties, "updated_field") - assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner_type", "owner", "project_number"}) + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner"}) // Verify DestructiveHint is set assert.NotNil(t, toolDef.Tool.Annotations) @@ -1946,19 +580,78 @@ func Test_ProjectsWrite(t *testing.T) { func Test_ProjectsWrite_AddProjectItem(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) - addedItem := map[string]any{"id": 2001, "archived_at": nil} - - t.Run("success organization", func(t *testing.T) { - mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostOrgsProjectsV2ItemsByProject: expectRequestBody(t, map[string]any{ - "type": "Issue", - "id": float64(123), - }).andThen(mockResponse(t, http.StatusCreated, addedItem)), - }) + t.Run("success organization with issue", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + // Mock resolveIssueNodeID query + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("item-owner"), + "repo": githubv4.String("item-repo"), + "issueNumber": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "id": "I_issue123", + }, + }, + }), + ), + // Mock project ID query for org + githubv4mock.NewQueryMatcher( + struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octo-org"), + "projectNumber": githubv4.Int(1), + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{ + "id": "PVT_project1", + }, + }, + }), + ), + // Mock addProjectV2ItemById mutation + githubv4mock.NewMutationMatcher( + struct { + AddProjectV2ItemByID struct { + Item struct { + ID githubv4.ID + } + } `graphql:"addProjectV2ItemById(input: $input)"` + }{}, + githubv4.AddProjectV2ItemByIdInput{ + ProjectID: githubv4.ID("PVT_project1"), + ContentID: githubv4.ID("I_issue123"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "addProjectV2ItemById": map[string]any{ + "item": map[string]any{ + "id": "PVTI_item1", + }, + }, + }), + ), + ) - client := gh.NewClient(mockedClient) + client := githubv4.NewClient(mockedClient) deps := BaseDeps{ - Client: client, + GQLClient: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ @@ -1966,7 +659,9 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { "owner": "octo-org", "owner_type": "org", "project_number": float64(1), - "item_id": float64(123), + "item_owner": "item-owner", + "item_repo": "item-repo", + "issue_number": float64(123), "item_type": "issue", }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) @@ -1979,13 +674,111 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.NotNil(t, response["id"]) + assert.Contains(t, response["message"], "Successfully added") + }) + + t.Run("success user with pull request", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + // Mock resolvePullRequestNodeID query + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + } `graphql:"pullRequest(number: $prNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("item-owner"), + "repo": githubv4.String("item-repo"), + "prNumber": githubv4.Int(456), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_pr456", + }, + }, + }), + ), + // Mock project ID query for user + githubv4mock.NewQueryMatcher( + struct { + User struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octo-user"), + "projectNumber": githubv4.Int(2), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "projectV2": map[string]any{ + "id": "PVT_project2", + }, + }, + }), + ), + // Mock addProjectV2ItemById mutation + githubv4mock.NewMutationMatcher( + struct { + AddProjectV2ItemByID struct { + Item struct { + ID githubv4.ID + } + } `graphql:"addProjectV2ItemById(input: $input)"` + }{}, + githubv4.AddProjectV2ItemByIdInput{ + ProjectID: githubv4.ID("PVT_project2"), + ContentID: githubv4.ID("PR_pr456"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "addProjectV2ItemById": map[string]any{ + "item": map[string]any{ + "id": "PVTI_item2", + }, + }, + }), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "add_project_item", + "owner": "octo-user", + "owner_type": "user", + "project_number": float64(2), + "item_owner": "item-owner", + "item_repo": "item-repo", + "pull_request_number": float64(456), + "item_type": "pull_request", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + assert.Contains(t, response["message"], "Successfully added") }) t.Run("missing item_type", func(t *testing.T) { - mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + mockedClient := githubv4mock.NewMockedHTTPClient() + client := githubv4.NewClient(mockedClient) deps := BaseDeps{ - Client: client, + GQLClient: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ @@ -1993,7 +786,9 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { "owner": "octo-org", "owner_type": "org", "project_number": float64(1), - "item_id": float64(123), + "item_owner": "item-owner", + "item_repo": "item-repo", + "issue_number": float64(123), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) @@ -2004,10 +799,10 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { }) t.Run("invalid item_type", func(t *testing.T) { - mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + mockedClient := githubv4mock.NewMockedHTTPClient() + client := githubv4.NewClient(mockedClient) deps := BaseDeps{ - Client: client, + GQLClient: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ @@ -2015,7 +810,9 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { "owner": "octo-org", "owner_type": "org", "project_number": float64(1), - "item_id": float64(123), + "item_owner": "item-owner", + "item_repo": "item-repo", + "issue_number": float64(123), "item_type": "invalid_type", }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) @@ -2027,10 +824,10 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { }) t.Run("unknown method", func(t *testing.T) { - mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + mockedClient := githubv4mock.NewMockedHTTPClient() + client := githubv4.NewClient(mockedClient) deps := BaseDeps{ - Client: client, + GQLClient: client, } handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ @@ -2051,14 +848,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, } @@ -2083,12 +880,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, } @@ -2119,7 +916,7 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { }), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -2142,7 +939,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, } @@ -2161,3 +958,311 @@ 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{ + "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"}, + }, + }), + ), + ) + + 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..69d4d6395f --- /dev/null +++ b/pkg/github/projects_v2_test.go @@ -0,0 +1,457 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ProjectsWrite_CreateProject(t *testing.T) { + t.Parallel() + + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + t.Run("success user project", func(t *testing.T) { + t.Parallel() + + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + User struct { + ID string + } `graphql:"user(login: $login)"` + }{}, + map[string]any{ + "login": githubv4.String("octocat"), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "id": "U_octocat", + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + CreateProjectV2 struct { + ProjectV2 struct { + ID string + Number int + Title string + URL string + } + } `graphql:"createProjectV2(input: $input)"` + }{}, + githubv4.CreateProjectV2Input{ + OwnerID: githubv4.ID("U_octocat"), + Title: githubv4.String("New Project"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createProjectV2": map[string]any{ + "projectV2": map[string]any{ + "id": "PVT_project123", + "number": 1, + "title": "New Project", + "url": "https://github.com/users/octocat/projects/1", + }, + }, + }), + ), + ) + + deps := BaseDeps{ + GQLClient: githubv4.NewClient(mockedClient), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_project", + "owner": "octocat", + "owner_type": "user", + "title": "New Project", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "PVT_project123", response["id"]) + assert.Equal(t, float64(1), response["number"]) + assert.Equal(t, "New Project", response["title"]) + assert.Equal(t, "https://github.com/users/octocat/projects/1", response["url"]) + }) + + t.Run("missing owner_type returns error", func(t *testing.T) { + t.Parallel() + + deps := BaseDeps{ + GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_project", + "owner": "octocat", + "title": "New Project", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "owner_type is required") + }) + + t.Run("invalid owner_type returns error", func(t *testing.T) { + t.Parallel() + + deps := BaseDeps{ + GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_project", + "owner": "octocat", + "owner_type": "invalid", + "title": "New Project", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "invalid owner_type") + assert.Contains(t, textContent.Text, "must be") + }) +} + +// resolveProjectNodeIDOrgMatcher returns a GraphQL query matcher for resolving +// an org project node ID via resolveProjectNodeID. +func resolveProjectNodeIDOrgMatcher(owner string, projectNumber int, nodeID string) githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // test constant + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{ + "id": nodeID, + }, + }, + }), + ) +} + +func createFieldMatcher() githubv4mock.Matcher { + return githubv4mock.NewMutationMatcher( + struct { + CreateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"createProjectV2Field(input: $input)"` + }{}, + githubv4.CreateProjectV2FieldInput{ + ProjectID: githubv4.ID("PVT_project1"), + DataType: githubv4.ProjectV2CustomFieldType("ITERATION"), + Name: githubv4.String("Sprint"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createProjectV2Field": map[string]any{ + "projectV2Field": map[string]any{ + "id": "PVTIF_field1", + "name": "Sprint", + }, + }, + }), + ) +} + +func updateFieldIterationResponse() githubv4mock.GQLResponse { + return githubv4mock.DataResponse(map[string]any{ + "updateProjectV2Field": map[string]any{ + "projectV2Field": map[string]any{ + "id": "PVTIF_field1", + "name": "Sprint", + "configuration": map[string]any{ + "iterations": []any{ + map[string]any{ + "id": "PVTI_iter1", + "title": "Sprint 1", + "startDate": "2025-01-20", + "duration": 7, + }, + }, + }, + }, + }, + }) +} + +func Test_ProjectsWrite_CreateIterationField(t *testing.T) { + t.Parallel() + + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + t.Run("success with iterations", func(t *testing.T) { + t.Parallel() + + mockGQLClient := githubv4mock.NewMockedHTTPClient( + resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"), + createFieldMatcher(), + githubv4mock.NewMutationMatcher( + struct { + UpdateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + Configuration struct { + Iterations []struct { + ID string + Title string + StartDate string + Duration int + } + } + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"updateProjectV2Field(input: $input)"` + }{}, + UpdateProjectV2FieldInput{ + FieldID: githubv4.ID("PVTIF_field1"), + IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{ + Duration: githubv4.Int(7), + StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)}, + Iterations: []ProjectV2IterationFieldIterationInput{ + { + Title: githubv4.String("Sprint 1"), + StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)}, + Duration: githubv4.Int(7), + }, + }, + }, + }, + nil, + updateFieldIterationResponse(), + ), + ) + + deps := BaseDeps{ + GQLClient: githubv4.NewClient(mockGQLClient), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_iteration_field", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "field_name": "Sprint", + "iteration_duration": float64(7), + "start_date": "2025-01-20", + "iterations": []any{ + map[string]any{ + "title": "Sprint 1", + "start_date": "2025-01-20", + "duration": float64(7), + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "PVTIF_field1", response["id"]) + }) + + t.Run("success without iterations", func(t *testing.T) { + t.Parallel() + + mockGQLClient := githubv4mock.NewMockedHTTPClient( + resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"), + createFieldMatcher(), + githubv4mock.NewMutationMatcher( + struct { + UpdateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + Configuration struct { + Iterations []struct { + ID string + Title string + StartDate string + Duration int + } + } + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"updateProjectV2Field(input: $input)"` + }{}, + UpdateProjectV2FieldInput{ + FieldID: githubv4.ID("PVTIF_field1"), + IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{ + Duration: githubv4.Int(7), + StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)}, + Iterations: []ProjectV2IterationFieldIterationInput{}, + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateProjectV2Field": map[string]any{ + "projectV2Field": map[string]any{ + "id": "PVTIF_field1", + "name": "Sprint", + "configuration": map[string]any{ + "iterations": []any{}, + }, + }, + }, + }), + ), + ) + + deps := BaseDeps{ + GQLClient: githubv4.NewClient(mockGQLClient), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_iteration_field", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "field_name": "Sprint", + "iteration_duration": float64(7), + "start_date": "2025-01-20", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "PVTIF_field1", response["id"]) + }) + + t.Run("success with auto-detected owner_type", func(t *testing.T) { + t.Parallel() + + // detectOwnerType uses REST to probe user first, then org + mockRESTClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusNotFound, nil), + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, map[string]any{ + "id": 1, + "node_id": "PVT_project1", + "title": "Org Project", + }), + }) + + mockGQLClient := githubv4mock.NewMockedHTTPClient( + resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"), + createFieldMatcher(), + githubv4mock.NewMutationMatcher( + struct { + UpdateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + Configuration struct { + Iterations []struct { + ID string + Title string + StartDate string + Duration int + } + } + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"updateProjectV2Field(input: $input)"` + }{}, + UpdateProjectV2FieldInput{ + FieldID: githubv4.ID("PVTIF_field1"), + IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{ + Duration: githubv4.Int(14), + StartDate: githubv4.Date{Time: time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC)}, + Iterations: []ProjectV2IterationFieldIterationInput{}, + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateProjectV2Field": map[string]any{ + "projectV2Field": map[string]any{ + "id": "PVTIF_field1", + "name": "Sprint", + "configuration": map[string]any{ + "iterations": []any{}, + }, + }, + }, + }), + ), + ) + + deps := BaseDeps{ + Client: mustNewGHClient(t, mockRESTClient), + GQLClient: githubv4.NewClient(mockGQLClient), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_iteration_field", + "owner": "octo-org", + "project_number": float64(1), + "field_name": "Sprint", + "iteration_duration": float64(14), + "start_date": "2025-02-01", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "PVTIF_field1", response["id"]) + }) +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 62952783e7..3910a96b95 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -8,14 +8,13 @@ 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/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 +33,14 @@ 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. + 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned. 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. + 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. `, - 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_review_comments", "get_reviews", "get_comments", "get_check_runs"}, }, "owner": { Type: "string", @@ -58,6 +58,13 @@ Possible options: Required: []string{"method", "owner", "repo", "pullNumber"}, } WithPagination(schema) + // get_review_comments uses GraphQL cursor-based pagination and accepts the + // `after` cursor. Other methods rely on the `page`/`perPage` parameters + // added by WithPagination and ignore `after`. + schema.Properties["after"] = &jsonschema.Schema{ + Type: "string", + Description: "Cursor for pagination, used only by the get_review_comments method. Pass the endCursor from the previous page's PageInfo to fetch the next page.", + } return NewTool( ToolsetMetadataPullRequests, @@ -101,7 +108,7 @@ Possible options: switch method { case "get": - result, err := GetPullRequest(ctx, client, deps.GetRepoAccessCache(), owner, repo, pullNumber, deps.GetFlags()) + result, err := GetPullRequest(ctx, client, deps, owner, repo, pullNumber) return result, nil, err case "get_diff": result, err := GetPullRequestDiff(ctx, client, owner, repo, pullNumber) @@ -121,13 +128,16 @@ 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()) + result, err := GetPullRequestReviewComments(ctx, gqlClient, deps, owner, repo, pullNumber, cursorPagination) return result, nil, err case "get_reviews": - result, err := GetPullRequestReviews(ctx, client, deps.GetRepoAccessCache(), owner, repo, pullNumber, deps.GetFlags()) + result, err := GetPullRequestReviews(ctx, client, deps, owner, repo, pullNumber, pagination) return result, nil, err case "get_comments": - result, err := GetIssueComments(ctx, client, deps.GetRepoAccessCache(), owner, repo, pullNumber, pagination, deps.GetFlags()) + result, err := GetIssueComments(ctx, client, deps, owner, repo, pullNumber, pagination) + return result, nil, err + case "get_check_runs": + result, err := GetPullRequestCheckRuns(ctx, client, owner, repo, pullNumber, pagination) return result, nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil @@ -135,7 +145,13 @@ Possible options: }) } -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 +197,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 +278,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 +366,9 @@ 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) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + minimalFiles := convertToMinimalPRFiles(files) - return utils.NewToolResultText(string(r)), nil + return MarshalledTextResult(minimalFiles), nil } // GraphQL types for review threads query @@ -340,7 +415,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 +482,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 +533,19 @@ 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 } +// PullRequestWriteUIResourceURI is the URI for the create_pull_request tool's MCP App UI resource. +const PullRequestWriteUIResourceURI = "ui://github-mcp-server/pr-write" + // 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"}, - } - return NewTool( ToolsetMetadataPullRequests, mcp.Tool{ @@ -518,10 +555,53 @@ 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", + }, + }, + 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 +610,38 @@ 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, + // check if this is a UI form submission. The UI sends _ui_submitted=true + // to distinguish form submissions from LLM calls. + uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") + + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted { + return utils.NewToolResultText(fmt.Sprintf("Ready to create a pull request in %s/%s. IMPORTANT: The PR has NOT been created yet. Do NOT tell the user the PR was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil + } + + // When creating PR, title/head/base are required + title, err := OptionalParam[string](args, "title") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - head, err := RequiredParam[string](args, "head") + head, err := OptionalParam[string](args, "head") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - base, err := RequiredParam[string](args, "base") + 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 { @@ -661,7 +761,7 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo", "pullNumber"}, } - return NewTool( + st := NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "update_pull_request", @@ -787,7 +887,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 @@ -898,6 +998,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,7 +1229,14 @@ 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 } @@ -1319,6 +1519,7 @@ type PullRequestReviewWriteParams struct { Body string Event string CommitID *string + ThreadID string } func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.ServerTool { @@ -1331,7 +1532,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 +1559,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 +1577,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 +1589,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 +1609,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 +1717,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 +1802,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 +1846,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 +2068,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 +2093,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 +2102,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..30d7f78d62 --- /dev/null +++ b/pkg/github/pullrequests_granular.go @@ -0,0 +1,739 @@ +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 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 + } + + 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: reviewers}) + 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 +} + +// 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..097651b66e 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,11 @@ 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": []interface{}{"reviewer1", "reviewer2"}, + "reviewers": []any{"reviewer1", "reviewer2"}, }, expectError: false, expectedPR: mockPRWithReviewers, @@ -261,14 +260,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 +279,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 +296,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 +313,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 +327,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 +385,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 +439,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 +497,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 +511,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 +590,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 +608,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 +628,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 +641,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 +670,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 +711,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 +719,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 +727,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 +746,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 +759,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 +847,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 +868,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 +894,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 +918,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 +939,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 +951,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 +971,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 +991,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 +1013,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 +1027,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 +1038,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 +1125,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 +1140,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 +1159,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 +1183,7 @@ func Test_GetPullRequestFiles(t *testing.T) { }), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "method": "get_files", "owner": "owner", "repo": "repo", @@ -1198,11 +1197,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,15 +1228,15 @@ 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) } }) } @@ -1298,7 +1297,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 +1308,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 +1325,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 +1343,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 +1357,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,6 +1403,161 @@ func Test_GetPullRequestStatus(t *testing.T) { } } +func Test_GetPullRequestCheckRuns(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.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"}) + + // 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]any + expectError bool + expectedCheckRuns *github.ListCheckRunsResults + expectedErrMsg string + }{ + { + name: "successful check runs fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposCommitsCheckRunsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCheckRuns), + }), + requestArgs: map[string]any{ + "method": "get_check_runs", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: false, + expectedCheckRuns: mockCheckRuns, + }, + { + name: "PR fetch fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), + requestArgs: map[string]any{ + "method": "get_check_runs", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get pull request", + }, + { + name: "check runs fetch fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + 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]any{ + "method": "get_check_runs", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: true, + expectedErrMsg: "failed to get check runs", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + 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) + + // 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) + + // 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_UpdatePullRequestBranch(t *testing.T) { // Verify tool definition once serverTool := UpdatePullRequestBranch(translations.NullTranslationHelper) @@ -1428,7 +1582,7 @@ func Test_UpdatePullRequestBranch(t *testing.T) { tests := []struct { name string mockedClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedUpdateResult *github.PullRequestBranchUpdateResponse expectedErrMsg string @@ -1436,13 +1590,13 @@ func Test_UpdatePullRequestBranch(t *testing.T) { { name: "successful branch update", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ "expected_head_sha": "abcd1234", }).andThen( mockResponse(t, http.StatusAccepted, mockUpdateResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "pullNumber": float64(42), @@ -1454,11 +1608,11 @@ func Test_UpdatePullRequestBranch(t *testing.T) { { name: "branch update without expected SHA", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{}).andThen( + PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{}).andThen( mockResponse(t, http.StatusAccepted, mockUpdateResult), ), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "pullNumber": float64(42), @@ -1474,7 +1628,7 @@ func Test_UpdatePullRequestBranch(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Merge conflict"}`)) }), }), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "pullNumber": float64(42), @@ -1487,7 +1641,7 @@ func Test_UpdatePullRequestBranch(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := UpdatePullRequestBranch(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1533,12 +1687,17 @@ func Test_GetPullRequestComments(t *testing.T) { assert.Contains(t, schema.Properties, "owner") assert.Contains(t, schema.Properties, "repo") assert.Contains(t, schema.Properties, "pullNumber") + // `after` is required for cursor-based pagination on get_review_comments + // to be reachable from MCP clients; without it in the schema, callers + // cannot advance past the first page (issue #2122). + assert.Contains(t, schema.Properties, "after") + assert.Equal(t, "string", schema.Properties["after"].Type) assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"}) tests := []struct { name string gqlHTTPClient *http.Client - requestArgs map[string]interface{} + requestArgs map[string]any expectError bool expectedErrMsg string lockdownEnabled bool @@ -1549,7 +1708,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 +1770,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 +1778,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 +1862,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 +1873,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 +1887,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 +1949,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 +1958,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 +1989,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 +2083,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 +2094,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 +2127,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 +2164,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 +2186,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 +2226,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 +2287,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 +2295,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 +2306,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 +2322,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 +2338,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 +2353,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,19 +2393,95 @@ 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") + 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, "Ready to create a pull request") + }) + + 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") + }) +} + +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") @@ -2254,6 +2551,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 +2728,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 +3009,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 +3425,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 +3526,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..d682b5c3d7 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -2,20 +2,21 @@ package github import ( "context" + "encoding/base64" "encoding/json" "fmt" "io" "net/http" - "net/url" "strings" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/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" ) @@ -147,6 +148,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 +182,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 +205,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 { @@ -322,9 +362,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 +399,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 +459,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 +541,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 }, ) } @@ -617,6 +654,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 +740,36 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultError("failed to get GitHub client"), nil, nil } + // attachIFC adds the IFC label to a successful tool result when + // IFC labels are enabled. The visibility lookup is performed + // lazily on first use and cached because GetFileContents has + // many possible return paths and would otherwise re-fetch on + // each. If the visibility lookup fails we skip the label rather + // than misclassify the result; the failure is not cached so a + // later return path can retry. + var ( + ifcLabelKnown bool + ifcIsPrivate bool + ) + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + return r + } + if !ifcLabelKnown { + isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo) + if err != nil { + return r + } + ifcIsPrivate = isPrivate + ifcLabelKnown = true + } + if r.Meta == nil { + r.Meta = mcp.Meta{} + } + r.Meta["ifc"] = ifc.LabelGetFileContents(ifcIsPrivate) + return r + } + rawOpts, fallbackUsed, err := resolveGitReference(ctx, client, owner, repo, ref, sha) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to resolve git reference: %s", err)), nil, nil @@ -710,98 +791,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 +1160,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 +1318,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 +1361,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 +1443,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,7 +1592,14 @@ 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) } @@ -1588,7 +1678,15 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get tag reference", resp, body), nil, nil } - // Then get the tag object + // Differentiate between lightweight and annotated tags since lightweight ones don't have a fetchable object + if ref.Object.GetType() == "commit" { + r, err := json.Marshal(ref) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + return utils.NewToolResultText(string(r)), nil, nil + } + tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, @@ -1682,7 +1780,14 @@ 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) } @@ -2097,3 +2202,111 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool }, ) } + +// ListRepositoryCollaborators creates a tool to list collaborators of a GitHub repository. +func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "list_repository_collaborators", + Description: t("TOOL_LIST_REPOSITORY_COLLABORATORS_DESCRIPTION", "List collaborators of a GitHub repository. Results are paginated; the response includes `nextPage`, `prevPage`, `firstPage`, and `lastPage` fields. To get the next page, use the `nextPage` value as the `page` parameter."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_REPOSITORY_COLLABORATORS_USER_TITLE", "List repository collaborators"), + ReadOnlyHint: true, + }, + InputSchema: func() *jsonschema.Schema { + schema := WithPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "affiliation": { + Type: "string", + Description: "Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all'", + Enum: []any{"outside", "direct", "all"}, + }, + }, + Required: []string{"owner", "repo"}, + }) + schema.Properties["page"].Description = "Page number for pagination (default 1, min 1)" + schema.Properties["perPage"].Description = "Results per page for pagination (default 30, min 1, max 100)" + return schema + }(), + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + affiliation, err := OptionalParam[string](args, "affiliation") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + opts := &github.ListCollaboratorsOptions{ + Affiliation: affiliation, + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list collaborators", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list collaborators", resp, body), nil, nil + } + + result := make([]MinimalCollaborator, 0, len(collaborators)) + for _, c := range collaborators { + result = append(result, MinimalCollaborator{ + Login: c.GetLogin(), + ID: c.GetID(), + RoleName: c.GetRoleName(), + }) + } + + response := map[string]any{ + "items": result, + "nextPage": resp.NextPage, + "prevPage": resp.PrevPage, + "firstPage": resp.FirstPage, + "lastPage": resp.LastPage, + } + + return MarshalledTextResult(response), nil, nil + }, + ) +} diff --git a/pkg/github/repositories_helper.go b/pkg/github/repositories_helper.go index 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..03535f1d26 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -14,7 +14,7 @@ import ( "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/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" @@ -64,9 +64,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 +78,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 +103,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 +138,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 +174,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 +190,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 +205,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 +230,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 +245,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 +259,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 +273,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 +285,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 +312,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 +398,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 +412,9 @@ func Test_GetFileContents(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) - mockRawClient := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"}) + client := mustNewGHClient(t, tc.mockedClient) + mockRawClient, err := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"}) + require.NoError(t, err) deps := BaseDeps{ Client: client, RawClient: mockRawClient, @@ -395,6 +462,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 +478,148 @@ func Test_GetFileContents(t *testing.T) { } } +func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { + t.Parallel() + + serverTool := GetFileContents(translations.NullTranslationHelper) + + mockRawContent := []byte("hello") + + makeMockClient := func(isPrivate bool) *http.Client { + return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{ + "name": "repo", + "default_branch": "main", + "private": isPrivate, + }), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + encodedContent := base64.StdEncoding.EncodeToString(mockRawContent) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + Content: github.Ptr(encodedContent), + Size: github.Ptr(len(mockRawContent)), + Encoding: github.Ptr("base64"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + }) + } + + reqParams := map[string]any{ + "owner": "octocat", + "repo": "repo", + "path": "README.md", + "ref": "refs/heads/main", + } + + t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false)), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assert.Nil(t, result.Meta, "result meta should be nil when insiders mode is disabled") + }) + + t.Run("insiders mode enabled on public repo emits public untrusted label", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode enabled on private repo emits private trusted label", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(true)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + + assert.Equal(t, "trusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusInternalServerError, "boom"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + encodedContent := base64.StdEncoding.EncodeToString(mockRawContent) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + Content: github.Ptr(encodedContent), + Size: github.Ptr(len(mockRawContent)), + Encoding: github.Ptr("base64"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + }) + deps := BaseDeps{ + Client: mustNewGHClient(t, mockedClient), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails") + + if result.Meta != nil { + _, hasIFC := result.Meta["ifc"] + assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails") + } + }) +} + func Test_ForkRepository(t *testing.T) { // Verify tool definition once serverTool := ForkRepository(translations.NullTranslationHelper) @@ -436,7 +653,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 +663,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 +678,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 +690,7 @@ func Test_ForkRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -546,7 +763,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 +775,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 +790,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 +813,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 +829,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 +848,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 +862,7 @@ func Test_CreateBranch(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -732,7 +949,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 +959,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 +975,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 +988,7 @@ func Test_GetCommit(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -826,6 +1043,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 +1128,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 +1138,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 +1157,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 +1166,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 +1250,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 +1267,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 +1279,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 +1374,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 +1382,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 +1397,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 +1411,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 +1427,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 +1436,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 +1460,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 +1472,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 +1490,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 +1499,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 +1512,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 +1538,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 +1552,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 +1561,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 +1574,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 +1593,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) }, - PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "GET /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + 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 +1620,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 +1636,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 +1670,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 +1728,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 +1738,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 +1748,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 +1762,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 +1772,7 @@ func Test_CreateRepository(t *testing.T) { ), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "name": "test-repo", "description": "Test repository", "organization": "testorg", @@ -1502,7 +1787,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", "auto_init": false, "description": "", @@ -1512,7 +1797,7 @@ func Test_CreateRepository(t *testing.T) { ), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "name": "test-repo", }, expectError: false, @@ -1529,7 +1814,7 @@ func Test_CreateRepository(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "name": "invalid-repo", }, expectError: true, @@ -1540,7 +1825,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 +1919,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 +1940,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 +1963,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 +1974,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 +1982,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 +2006,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 +2030,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 +2058,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 +2086,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 +2115,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 +2149,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 +2178,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 +2201,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 +2235,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 +2264,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 +2291,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 +2333,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 +2362,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 +2373,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 +2381,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 +2413,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 +2432,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 +2461,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 +2488,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 +2512,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 +2539,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 +2563,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 +2641,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 +2663,7 @@ func Test_ListBranches(t *testing.T) { }, { name: "missing owner", - args: map[string]interface{}{ + args: map[string]any{ "repo": "repo", }, mockResponses: []MockBackendOption{}, @@ -2387,7 +2672,7 @@ func Test_ListBranches(t *testing.T) { }, { name: "missing repo", - args: map[string]interface{}{ + args: map[string]any{ "owner": "owner", }, mockResponses: []MockBackendOption{}, @@ -2399,7 +2684,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 +2774,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 +2795,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 +2812,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 +2823,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 +2836,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 +2857,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 +2872,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 +2897,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 +2951,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 +2969,7 @@ func Test_ListTags(t *testing.T) { ), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -2702,7 +2987,7 @@ func Test_ListTags(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -2714,7 +2999,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 +3027,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 +3057,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 +3086,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 +3101,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 +3114,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 +3133,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 +3146,7 @@ func Test_GetTag(t *testing.T) { mockedClient: NewMockedHTTPClient( WithRequestMatch( GetReposGitRefByOwnerByRepoByRef, - mockTagRef, + mockAnnotatedTagRef, ), WithRequestMatchHandler( GetReposGitTagsByOwnerByRepoByTagSHA, @@ -2861,7 +3156,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 +3164,33 @@ func Test_GetTag(t *testing.T) { expectError: true, expectedErrMsg: "failed to get tag object", }, + { + name: "successful lightweight tag retrieval", + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, + expectPath( + t, + "/repos/owner/repo/git/ref/tags/v1.0.1", + ).andThen( + mockResponse(t, http.StatusOK, mockLightweightTagRef), + ), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "tag": "v1.0.1", + }, + expectError: false, + expectedRef: mockLightweightTagRef, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2901,16 +3217,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()) + } - 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) + // Parse and verify the result - lightweight tag (reference only) + if tc.expectedRef != nil { + var returnedRef github.Reference + err = json.Unmarshal([]byte(textContent.Text), &returnedRef) + require.NoError(t, err) + + assert.Equal(t, tc.expectedRef.GetRef(), returnedRef.GetRef()) + assert.Equal(t, tc.expectedRef.Object.GetType(), returnedRef.Object.GetType()) + assert.Equal(t, tc.expectedRef.Object.GetSHA(), returnedRef.Object.GetSHA()) + } }) } } @@ -2945,7 +3274,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 +3287,7 @@ func Test_ListReleases(t *testing.T) { mockReleases, ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -2976,7 +3305,7 @@ func Test_ListReleases(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -2987,7 +3316,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 +3332,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 +3366,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 +3379,7 @@ func Test_GetLatestRelease(t *testing.T) { mockRelease, ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -3067,7 +3397,7 @@ func Test_GetLatestRelease(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "owner", "repo": "repo", }, @@ -3078,7 +3408,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 +3463,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 +3476,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 +3487,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 +3497,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 +3507,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 +3525,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 +3544,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 +3556,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, } @@ -3671,7 +4001,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 +4090,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 +4106,7 @@ func Test_ListStarredRepositories(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{}, + requestArgs: map[string]any{}, expectError: false, expectedCount: 2, }, @@ -3791,7 +4121,7 @@ func Test_ListStarredRepositories(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "username": "testuser", }, expectError: false, @@ -3808,7 +4138,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 +4147,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 +4205,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 +4219,7 @@ func Test_StarRepository(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "testowner", "repo": "testrepo", }, @@ -3906,7 +4236,7 @@ func Test_StarRepository(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "testowner", "repo": "nonexistent", }, @@ -3918,7 +4248,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 +4296,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 +4310,7 @@ func Test_UnstarRepository(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "testowner", "repo": "testrepo", }, @@ -3997,7 +4327,7 @@ func Test_UnstarRepository(t *testing.T) { }), ), ), - requestArgs: map[string]interface{}{ + requestArgs: map[string]any{ "owner": "testowner", "repo": "nonexistent", }, @@ -4009,7 +4339,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 +4368,149 @@ func Test_UnstarRepository(t *testing.T) { }) } } + +func Test_ListRepositoryCollaborators(t *testing.T) { + // Verify tool definition once + serverTool := ListRepositoryCollaborators(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + assert.Equal(t, "list_repository_collaborators", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.True(t, tool.Annotations.ReadOnlyHint) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "affiliation") + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) + + mockCollaborators := []*github.User{ + { + Login: github.Ptr("user1"), + ID: github.Ptr(int64(101)), + RoleName: github.Ptr("admin"), + }, + { + Login: github.Ptr("user2"), + ID: github.Ptr(int64(102)), + RoleName: github.Ptr("write"), + }, + } + + tests := []struct { + name string + args map[string]any + mockResponses []MockBackendOption + wantErr bool + errContains string + }{ + { + name: "success", + args: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + mockResponses: []MockBackendOption{ + WithRequestMatch( + ListCollaborators, + mockCollaborators, + ), + }, + }, + { + name: "success with affiliation filter", + args: map[string]any{ + "owner": "owner", + "repo": "repo", + "affiliation": "direct", + }, + mockResponses: []MockBackendOption{ + WithRequestMatch( + ListCollaborators, + mockCollaborators, + ), + }, + }, + { + name: "missing owner", + args: map[string]any{ + "repo": "repo", + }, + mockResponses: []MockBackendOption{}, + errContains: "missing required parameter: owner", + }, + { + name: "missing repo", + args: map[string]any{ + "owner": "owner", + }, + mockResponses: []MockBackendOption{}, + errContains: "missing required parameter: repo", + }, + { + name: "empty collaborators returns empty array", + args: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + mockResponses: []MockBackendOption{ + WithRequestMatch( + ListCollaborators, + []*github.User{}, + ), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := mustNewGHClient(t, NewMockedHTTPClient(tt.mockResponses...)) + deps := BaseDeps{ + Client: mockClient, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(tt.args) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.NotNil(t, result) + + if tt.errContains != "" { + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tt.errContains) + return + } + + textContent := getTextResult(t, result) + require.NotEmpty(t, textContent.Text) + + var response struct { + Items []MinimalCollaborator `json:"items"` + NextPage int `json:"nextPage"` + PrevPage int `json:"prevPage"` + FirstPage int `json:"firstPage"` + LastPage int `json:"lastPage"` + } + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + if tt.name == "empty collaborators returns empty array" { + assert.Empty(t, response.Items) + return + } + + collaborators := response.Items + assert.Len(t, collaborators, 2) + assert.Equal(t, "user1", collaborators[0].Login) + assert.Equal(t, int64(101), collaborators[0].ID) + assert.Equal(t, "admin", collaborators[0].RoleName) + assert.Equal(t, "user2", collaborators[1].Login) + assert.Equal(t, int64(102), collaborators[1].ID) + assert.Equal(t, "write", collaborators[1].RoleName) + }) + } +} diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index ee43e9d046..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" ) @@ -102,15 +102,16 @@ func GetRepositoryResourcePrContent(t translations.TranslationHelperFunc) invent // repositoryResourceContentsHandlerFunc returns a ResourceHandlerFunc that creates handlers on-demand. func repositoryResourceContentsHandlerFunc(resourceURITemplate *uritemplate.Template) inventory.ResourceHandlerFunc { - return func(deps any) mcp.ResourceHandler { - d := deps.(ToolDependencies) - return RepositoryResourceContentsHandler(d, resourceURITemplate) + return func(_ any) mcp.ResourceHandler { + return RepositoryResourceContentsHandler(resourceURITemplate) } } // RepositoryResourceContentsHandler returns a handler function for repository content requests. -func RepositoryResourceContentsHandler(deps ToolDependencies, resourceURITemplate *uritemplate.Template) mcp.ResourceHandler { +// It retrieves ToolDependencies from the context at call time via MustDepsFromContext. +func RepositoryResourceContentsHandler(resourceURITemplate *uritemplate.Template) mcp.ResourceHandler { return func(ctx context.Context, request *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + deps := MustDepsFromContext(ctx) // Match the URI to extract parameters uriValues := resourceURITemplate.Match(request.Params.URI) if uriValues == nil { @@ -190,13 +191,14 @@ func RepositoryResourceContentsHandler(deps ToolDependencies, resourceURITemplat } 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") @@ -256,3 +258,54 @@ func RepositoryResourceContentsHandler(deps ToolDependencies, resourceURITemplat } } } + +// 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 b55b821afd..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 ( @@ -26,7 +35,7 @@ func Test_repositoryResourceContents(t *testing.T) { name string mockedClient *http.Client uri string - handlerFn func(deps ToolDependencies) mcp.ResourceHandler + handlerFn func() mcp.ResourceHandler expectedResponseType resourceResponseType expectError string expectedResult *mcp.ReadResourceResult @@ -41,8 +50,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo:///repo/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "owner is required", @@ -57,8 +66,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner//refs/heads/main/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceBranchContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceBranchContentURITemplate) }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "repo is required", @@ -73,8 +82,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/contents/data.png", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeBlob, expectedResult: &mcp.ReadResourceResult{ @@ -94,8 +103,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -117,8 +126,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/contents/pkg/github/actions.go", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -138,8 +147,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/refs/heads/main/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceBranchContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceBranchContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -159,8 +168,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/refs/tags/v1.0.0/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceTagContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceTagContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -180,8 +189,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/sha/abc123/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceCommitContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceCommitContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -206,8 +215,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/refs/pull/42/head/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourcePrContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourcePrContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -226,8 +235,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/contents/nonexistent.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "404 Not Found", @@ -236,13 +245,15 @@ 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, } - handler := tc.handlerFn(deps) + ctx := ContextWithDeps(context.Background(), deps) + handler := tc.handlerFn() request := &mcp.ReadResourceRequest{ Params: &mcp.ReadResourceParams{ @@ -250,7 +261,7 @@ func Test_repositoryResourceContents(t *testing.T) { }, } - resp, err := handler(context.TODO(), request) + resp, err := handler(ctx, request) if tc.expectError != "" { require.ErrorContains(t, err, tc.expectError) @@ -271,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/scope_filter_test.go b/pkg/github/scope_filter_test.go index 451d1a64e7..9cdd4db19b 100644 --- a/pkg/github/scope_filter_test.go +++ b/pkg/github/scope_filter_test.go @@ -167,11 +167,12 @@ func TestCreateToolScopeFilter_Integration(t *testing.T) { filter := CreateToolScopeFilter([]string{"repo"}) // Build inventory with the filter - inv := inventory.NewBuilder(). + inv, err := inventory.NewBuilder(). SetTools(tools). WithToolsets([]string{"test"}). WithFilter(filter). Build() + require.NoError(t, err) // Get available tools availableTools := inv.AvailableTools(context.Background()) diff --git a/pkg/github/search.go b/pkg/github/search.go index 552fbfe781..9a8d182887 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -8,11 +8,12 @@ import ( "net/http" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -161,11 +162,37 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo } } - return utils.NewToolResultText(string(r)), nil, nil + callResult := utils.NewToolResultText(string(r)) + if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + attachSearchRepositoriesIFCLabel(result.Repositories, callResult) + } + return callResult, nil, nil }, ) } +// attachSearchRepositoriesIFCLabel joins per-repository IFC labels across +// every matched repository and attaches the result to callResult. Visibility +// is read directly from the search response — no extra API call. The join +// math is shared with search_issues via ifc.LabelSearchIssues: integrity is +// always untrusted; confidentiality is private if any matched repository is +// private, otherwise public. +func attachSearchRepositoriesIFCLabel(repos []*github.Repository, callResult *mcp.CallToolResult) { + if callResult == nil || callResult.IsError { + return + } + + visibilities := make([]bool, 0, len(repos)) + for _, repo := range repos { + visibilities = append(visibilities, repo.GetPrivate()) + } + + if callResult.Meta == nil { + callResult.Meta = mcp.Meta{} + } + callResult.Meta["ifc"] = ifc.LabelSearchIssues(visibilities) +} + // SearchCode creates a tool to search for code across GitHub repositories. func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ @@ -173,7 +200,7 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { Properties: map[string]*jsonschema.Schema{ "query": { Type: "string", - Description: "Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more.", + Description: "Search query (GitHub code search REST). Implicit AND between terms; supports `OR`, `NOT`, and `\"quoted phrase\"` for exact match. Qualifiers: `repo:owner/repo`, `org:`, `user:`, `language:`, `path:dir` (prefix match), `filename:exact.ext`, `extension:`, `in:file`, `in:path`, `size:`, `is:archived`, `is:fork`. Max 256 chars. Examples: `WithContext language:go org:github`; `\"package main\" repo:o/r`; `func extension:go path:cmd repo:o/r`; `NOT TODO language:go repo:o/r`.", }, "sort": { Type: "string", @@ -220,8 +247,9 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { } opts := &github.SearchOptions{ - Sort: sort, - Order: order, + Sort: sort, + Order: order, + TextMatch: true, ListOptions: github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, @@ -251,7 +279,27 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search code", resp, body), nil, nil } - r, err := json.Marshal(result) + minimalItems := make([]MinimalCodeResult, 0, len(result.CodeResults)) + for _, code := range result.CodeResults { + item := MinimalCodeResult{ + Name: code.GetName(), + Path: code.GetPath(), + SHA: code.GetSHA(), + TextMatches: code.TextMatches, + } + if code.Repository != nil { + item.Repository = code.Repository.GetFullName() + } + minimalItems = append(minimalItems, item) + } + + minimalResult := &MinimalCodeSearchResult{ + TotalCount: result.GetTotal(), + IncompleteResults: result.GetIncompleteResults(), + Items: minimalItems, + } + + r, err := json.Marshal(minimalResult) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } @@ -430,3 +478,109 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) } + +// SearchCommits creates a tool to search for commits across GitHub repositories. +func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "query": { + Type: "string", + Description: "Commit search query (GitHub commit search REST). Searches commit messages on the default branch only. Scope the search with `repo:owner/repo`, `org:`, or `user:` (queries without a scope qualifier match across all of GitHub and are usually not what you want). Other qualifiers: `author:`, `committer:`, `author-name:`, `committer-name:`, `author-email:`, `committer-email:`, `author-date:`, `committer-date:` (supports `>`, `<`, `>=`, `<=`, and `YYYY-MM-DD..YYYY-MM-DD` ranges), `merge:true|false`, `hash:`, `tree:`, `parent:`, `is:public`. Examples: `repo:owner/repo fix panic`; `org:github author:defunkt committer-date:>=2024-01-01`; `\"refactor cache\" repo:o/r`; `hash:abc1234 repo:o/r`.", + }, + "sort": { + Type: "string", + Description: "Sort by author or committer date (defaults to best match)", + Enum: []any{"author-date", "committer-date"}, + }, + "order": { + Type: "string", + Description: "Sort order", + Enum: []any{"asc", "desc"}, + }, + }, + Required: []string{"query"}, + } + WithPagination(schema) + + return NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "search_commits", + Description: t("TOOL_SEARCH_COMMITS_DESCRIPTION", "Search for commits across GitHub repositories using GitHub's commit search syntax. Useful for finding specific changes, authors, or messages across one or many repositories. Searches the default branch only."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_SEARCH_COMMITS_USER_TITLE", "Search commits"), + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + query, err := RequiredParam[string](args, "query") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + sort, err := OptionalParam[string](args, "sort") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + order, err := OptionalParam[string](args, "order") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + opts := &github.SearchOptions{ + Sort: sort, + Order: order, + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + result, resp, err := client.Search.Commits(ctx, query, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to search commits with query '%s'", query), + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search commits", resp, body), nil, nil + } + + minimalCommits := make([]MinimalCommitSearchItem, 0, len(result.Commits)) + for _, commit := range result.Commits { + minimalCommits = append(minimalCommits, convertCommitResultToMinimalCommit(commit)) + } + + minimalResult := &MinimalSearchCommitsResult{ + TotalCount: result.GetTotal(), + IncompleteResults: result.GetIncompleteResults(), + Items: minimalCommits, + } + + r, err := json.Marshal(minimalResult) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) +} diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index e15758c3e7..fa48bf19a1 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -5,10 +5,11 @@ import ( "encoding/json" "net/http" "testing" + "time" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/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 any private match emits private untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient([]repoFixture{ + {owner: "octocat", name: "private-repo", isPrivate: true}, + {owner: "octocat", name: "public-repo"}, + })), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(nil)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) } func Test_SearchRepositories_FullOutput(t *testing.T) { @@ -194,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..e2605274f0 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -95,6 +95,36 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv } func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "state": { + Type: "string", + Description: "Filter by state", + Enum: []any{"open", "resolved"}, + }, + "secret_type": { + Type: "string", + Description: "A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter.", + }, + "resolution": { + Type: "string", + Description: "Filter by resolution", + Enum: []any{"false_positive", "wont_fix", "revoked", "pattern_edited", "pattern_deleted", "used_in_tests"}, + }, + }, + Required: []string{"owner", "repo"}, + } + WithPagination(schema) + return NewTool( ToolsetMetadataSecretProtection, mcp.Tool{ @@ -104,34 +134,7 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se Title: t("TOOL_LIST_SECRET_SCANNING_ALERTS_USER_TITLE", "List secret scanning alerts"), ReadOnlyHint: true, }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "The owner of the repository.", - }, - "repo": { - Type: "string", - Description: "The name of the repository.", - }, - "state": { - Type: "string", - Description: "Filter by state", - Enum: []any{"open", "resolved"}, - }, - "secret_type": { - Type: "string", - Description: "A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter.", - }, - "resolution": { - Type: "string", - Description: "Filter by resolution", - Enum: []any{"false_positive", "wont_fix", "revoked", "pattern_edited", "pattern_deleted", "used_in_tests"}, - }, - }, - Required: []string{"owner", "repo"}, - }, + InputSchema: schema, }, []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -156,11 +159,24 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se return utils.NewToolResultError(err.Error()), nil, nil } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - alerts, resp, err := client.SecretScanning.ListAlertsForRepo(ctx, owner, repo, &github.SecretScanningAlertListOptions{State: state, SecretType: secretType, Resolution: resolution}) + alerts, resp, err := client.SecretScanning.ListAlertsForRepo(ctx, owner, repo, &github.SecretScanningAlertListOptions{ + State: state, + SecretType: secretType, + Resolution: resolution, + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go index 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..ec84e27b15 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go index bfc4c6985e..f45c2e4210 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/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_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 +52,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 +68,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 +83,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 +92,7 @@ func Test_ListGlobalSecurityAdvisories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -155,7 +155,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 +165,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 +179,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 +193,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 +204,7 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -270,7 +270,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 +285,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 +306,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 +326,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 +337,7 @@ func Test_ListRepositorySecurityAdvisories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -403,7 +403,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 +418,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 +438,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 +457,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 +467,7 @@ func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) diff --git a/pkg/github/server.go b/pkg/github/server.go index 8248da58fd..f56ac7d3a8 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) } - 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 a59cd9a937..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,20 +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) GetT() translations.TranslationHelperFunc { return s.t } -func (s stubDeps) GetFlags() FeatureFlags { return s.flags } -func (s stubDeps) GetContentWindowSize() int { return s.contentWindowSize } +func (s stubDeps) GetRepoAccessCache(_ context.Context) (*lockdown.RepoAccessCache, error) { + return s.repoAccessCache, nil +} +func (s stubDeps) GetT() translations.TranslationHelperFunc { return s.t } +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) } } @@ -75,9 +99,32 @@ 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 { @@ -88,7 +135,7 @@ func stubFeatureFlags(enabledFlags map[string]bool) FeatureFlags { func badRequestHandler(msg string) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { - structuredErrorResponse := github.ErrorResponse{ + structuredErrorResponse := gogithub.ErrorResponse{ Message: msg, } @@ -101,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 b15c4fc9a8..d1d585b3fa 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -2,11 +2,12 @@ package github import ( "context" + "slices" "strings" "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/shurcooL/githubv4" ) @@ -28,10 +29,11 @@ var ( Icon: "check-circle", } ToolsetMetadataContext = inventory.ToolsetMetadata{ - ID: "context", - Description: "Tools that provide context about the current user and GitHub context you are operating in", - Default: true, - Icon: "person", + ID: "context", + Description: "Tools that provide context about the current user and GitHub context you are operating in", + Default: true, + Icon: "person", + InstructionsFunc: generateContextToolsetInstructions, } ToolsetMetadataRepos = inventory.ToolsetMetadata{ ID: "repos", @@ -45,16 +47,18 @@ var ( Icon: "git-branch", } ToolsetMetadataIssues = inventory.ToolsetMetadata{ - ID: "issues", - Description: "GitHub Issues related tools", - Default: true, - Icon: "issue-opened", + ID: "issues", + Description: "GitHub Issues related tools", + Default: true, + Icon: "issue-opened", + InstructionsFunc: generateIssuesToolsetInstructions, } ToolsetMetadataPullRequests = inventory.ToolsetMetadata{ - ID: "pull_requests", - Description: "GitHub Pull Request related tools", - Default: true, - Icon: "git-pull-request", + ID: "pull_requests", + Description: "GitHub Pull Request related tools", + Default: true, + Icon: "git-pull-request", + InstructionsFunc: generatePullRequestsToolsetInstructions, } ToolsetMetadataUsers = inventory.ToolsetMetadata{ ID: "users", @@ -93,9 +97,10 @@ var ( Icon: "bell", } ToolsetMetadataDiscussions = inventory.ToolsetMetadata{ - ID: "discussions", - Description: "GitHub Discussions related tools", - Icon: "comment-discussion", + ID: "discussions", + Description: "GitHub Discussions related tools", + Icon: "comment-discussion", + InstructionsFunc: generateDiscussionsToolsetInstructions, } ToolsetMetadataGists = inventory.ToolsetMetadata{ ID: "gists", @@ -108,33 +113,45 @@ var ( Icon: "shield", } ToolsetMetadataProjects = inventory.ToolsetMetadata{ - ID: "projects", - Description: "GitHub Projects related tools", - Icon: "project", + ID: "projects", + Description: "GitHub Projects related tools", + Icon: "project", + InstructionsFunc: generateProjectsToolsetInstructions, } ToolsetMetadataStargazers = inventory.ToolsetMetadata{ ID: "stargazers", 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", @@ -150,7 +167,7 @@ var ( // AllTools returns all tools with their embedded toolset metadata. // Tool functions return ServerTool directly with toolset info. func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { - return []inventory.ServerTool{ + return withCSVOutput([]inventory.ServerTool{ // Context tools GetMe(t), GetTeams(t), @@ -161,6 +178,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GetFileContents(t), ListCommits(t), SearchCode(t), + SearchCommits(t), GetCommit(t), ListBranches(t), ListTags(t), @@ -177,6 +195,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ListStarredRepositories(t), StarRepository(t), UnstarRepository(t), + ListRepositoryCollaborators(t), // Git tools GetRepositoryTree(t), @@ -185,10 +204,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 @@ -205,9 +226,13 @@ 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 security tools GetCodeScanningAlert(t), @@ -233,24 +258,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), @@ -269,17 +280,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), @@ -289,7 +289,34 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GetLabelForLabelsToolset(t), ListLabels(t), LabelWrite(t), - } + + // Granular issue tools (feature-flagged, replace consolidated issue_write/sub_issue_write) + GranularCreateIssue(t), + GranularUpdateIssueTitle(t), + GranularUpdateIssueBody(t), + GranularUpdateIssueAssignees(t), + GranularUpdateIssueLabels(t), + GranularUpdateIssueMilestone(t), + GranularUpdateIssueType(t), + GranularUpdateIssueState(t), + GranularAddSubIssue(t), + GranularRemoveSubIssue(t), + GranularReprioritizeSubIssue(t), + GranularSetIssueFields(t), + + // Granular pull request tools (feature-flagged, replace consolidated update_pull_request/pull_request_review_write) + GranularUpdatePullRequestTitle(t), + GranularUpdatePullRequestBody(t), + GranularUpdatePullRequestState(t), + GranularUpdatePullRequestDraftState(t), + GranularRequestPullRequestReviewers(t), + GranularCreatePullRequestReview(t), + GranularSubmitPendingPullRequestReview(t), + GranularDeletePendingPullRequestReview(t), + GranularAddPullRequestReviewComment(t), + GranularResolveReviewThread(t), + GranularUnresolveReviewThread(t), + }) } // ToBoolPtr converts a bool to a *bool pointer. @@ -309,7 +336,8 @@ func ToStringPtr(s string) *string { // GenerateToolsetsHelp generates the help text for the toolsets flag func GenerateToolsetsHelp() string { // Get toolset group to derive defaults and available toolsets - r := NewInventory(stubTranslator).Build() + // Build() can only fail if WithTools specifies invalid tools - not used here + r, _ := NewInventory(stubTranslator).Build() // Format default tools from metadata using strings.Builder var defaultBuf strings.Builder @@ -321,8 +349,8 @@ func GenerateToolsetsHelp() string { defaultBuf.WriteString(string(id)) } - // Get all available toolsets (excludes context and dynamic for display) - allToolsets := r.AvailableToolsets("context", "dynamic") + // Get all available toolsets (excludes context for display) + allToolsets := r.AvailableToolsets("context") var availableBuf strings.Builder const maxLineLength = 70 currentLine := "" @@ -391,7 +419,8 @@ func AddDefaultToolset(result []string) []string { result = RemoveToolset(result, string(ToolsetMetadataDefault.ID)) // Get default toolset IDs from the Inventory - r := NewInventory(stubTranslator).Build() + // Build() can only fail if WithTools specifies invalid tools - not used here + r, _ := NewInventory(stubTranslator).Build() for _, id := range r.DefaultToolsetIDs() { if !seen[string(id)] { result = append(result, string(id)) @@ -411,12 +440,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. @@ -443,7 +467,8 @@ func CleanTools(toolNames []string) []string { // GetDefaultToolsetIDs returns the IDs of toolsets marked as Default. // This is a convenience function that builds an inventory to determine defaults. func GetDefaultToolsetIDs() []string { - r := NewInventory(stubTranslator).Build() + // Build() can only fail if WithTools specifies invalid tools - not used here + r, _ := NewInventory(stubTranslator).Build() ids := r.DefaultToolsetIDs() result := make([]string, len(ids)) for i, id := range ids { @@ -457,7 +482,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_icons_test.go b/pkg/github/toolset_icons_test.go index fd9cec462b..7cfe4bef77 100644 --- a/pkg/github/toolset_icons_test.go +++ b/pkg/github/toolset_icons_test.go @@ -13,7 +13,8 @@ import ( // This prevents broken icon references from being merged. func TestAllToolsetIconsExist(t *testing.T) { // Get all available toolsets from the inventory - inv := NewInventory(stubTranslator).Build() + inv, err := NewInventory(stubTranslator).Build() + require.NoError(t, err) toolsets := inv.AvailableToolsets() // Also test remote-only toolsets @@ -72,7 +73,8 @@ func TestToolsetMetadataHasIcons(t *testing.T) { "default": true, // Meta-toolset } - inv := NewInventory(stubTranslator).Build() + inv, err := NewInventory(stubTranslator).Build() + require.NoError(t, err) toolsets := inv.AvailableToolsets() for _, ts := range toolsets { diff --git a/pkg/github/instructions.go b/pkg/github/toolset_instructions.go similarity index 65% rename from pkg/github/instructions.go rename to pkg/github/toolset_instructions.go index 3a5fb54bb7..ba6659612a 100644 --- a/pkg/github/instructions.go +++ b/pkg/github/toolset_instructions.go @@ -1,77 +1,49 @@ package github -import ( - "os" - "slices" - "strings" -) - -// GenerateInstructions creates server instructions based on enabled toolsets -func GenerateInstructions(enabledToolsets []string) string { - // For testing - add a flag to disable instructions - if os.Getenv("DISABLE_INSTRUCTIONS") == "true" { - return "" // Baseline mode - } +import "github.com/github/github-mcp-server/pkg/inventory" - var instructions []string +// Toolset instruction functions - these generate context-aware instructions for each toolset. +// They are called during inventory build to generate server instructions. - // Core instruction - always included if context toolset enabled - if slices.Contains(enabledToolsets, "context") { - instructions = append(instructions, "Always call 'get_me' first to understand current user permissions and context.") - } +func generateContextToolsetInstructions(_ *inventory.Inventory) string { + return "Always call 'get_me' first to understand current user permissions and context." +} - // Individual toolset instructions - for _, toolset := range enabledToolsets { - if inst := getToolsetInstructions(toolset, enabledToolsets); inst != "" { - instructions = append(instructions, inst) - } - } +func generateIssuesToolsetInstructions(_ *inventory.Inventory) string { + return `## Issues - // Base instruction with context management - baseInstruction := `The GitHub MCP Server provides tools to interact with GitHub platform. +Check 'list_issue_types' first for organizations to use proper issue types. Use 'search_issues' before creating new issues to avoid duplicates. Always set 'state_reason' when closing issues.` +} -Tool selection guidance: - 1. Use 'list_*' tools for broad, simple retrieval and pagination of all items of a type (e.g., all issues, all PRs, all branches) with basic filtering. - 2. Use 'search_*' tools for targeted queries with specific criteria, keywords, or complex filters (e.g., issues with certain text, PRs by author, code containing functions). +func generatePullRequestsToolsetInstructions(inv *inventory.Inventory) string { + instructions := `## Pull Requests -Context management: - 1. Use pagination whenever possible with batches of 5-10 items. - 2. Use minimal_output parameter set to true if the full information is not needed to accomplish a task. +PR review workflow: Always use 'pull_request_review_write' with method 'create' to create a pending review, then 'add_comment_to_pending_review' to add comments, and finally 'pull_request_review_write' with method 'submit_pending' to submit the review for complex reviews with line-specific comments.` -Tool usage guidance: - 1. For 'search_*' tools: Use separate 'sort' and 'order' parameters if available for sorting results - do not include 'sort:' syntax in query strings. Query strings should contain only search criteria (e.g., 'org:google language:python'), not sorting instructions.` + if inv.HasToolset("repos") { + instructions += ` - allInstructions := []string{baseInstruction} - allInstructions = append(allInstructions, instructions...) +Before creating a pull request, search for pull request templates in the repository. Template files are called pull_request_template.md or they're located in '.github/PULL_REQUEST_TEMPLATE' directory. Use the template content to structure the PR description and then call create_pull_request tool.` + } + return instructions +} - return strings.Join(allInstructions, " ") +func generateDiscussionsToolsetInstructions(_ *inventory.Inventory) string { + return `## Discussions + +Use 'list_discussion_categories' to understand available categories before creating discussions. Filter by category for better organization.` } -// getToolsetInstructions returns specific instructions for individual toolsets -func getToolsetInstructions(toolset string, enabledToolsets []string) string { - switch toolset { - case "pull_requests": - pullRequestInstructions := `## Pull Requests +func generateProjectsToolsetInstructions(_ *inventory.Inventory) string { + return `## Projects -PR review workflow: Always use 'pull_request_review_write' with method 'create' to create a pending review, then 'add_comment_to_pending_review' to add comments, and finally 'pull_request_review_write' with method 'submit_pending' to submit the review for complex reviews with line-specific comments.` - if slices.Contains(enabledToolsets, "repos") { - pullRequestInstructions += ` +Workflow: 1) list_project_fields (get field IDs), 2) list_project_items (with pagination), 3) optional updates. -Before creating a pull request, search for pull request templates in the repository. Template files are called pull_request_template.md or they're located in '.github/PULL_REQUEST_TEMPLATE' directory. Use the template content to structure the PR description and then call create_pull_request tool.` - } - return pullRequestInstructions - case "issues": - return `## Issues +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. -Check 'list_issue_types' first for organizations to use proper issue types. Use 'search_issues' before creating new issues to avoid duplicates. Always set 'state_reason' when closing issues.` - case "discussions": - return `## Discussions - -Use 'list_discussion_categories' to understand available categories before creating discussions. Filter by category for better organization.` - case "projects": - return `## Projects +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. -Workflow: 1) list_project_fields (get field IDs), 2) list_project_items (with pagination), 3) optional updates. +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. @@ -137,7 +109,4 @@ Common Qualifier Glossary (items): Never: - Infer field IDs; fetch via list_project_fields. - Drop 'fields' param on subsequent pages if field values are needed.` - default: - return "" - } } diff --git a/pkg/github/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..ab3ebfd163 --- /dev/null +++ b/pkg/github/ui_resources.go @@ -0,0 +1,106 @@ +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) { + // 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 + }, + ) + + // 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 + }, + ) +} diff --git a/pkg/github/ui_resources_test.go b/pkg/github/ui_resources_test.go new file mode 100644 index 0000000000..928950ac73 --- /dev/null +++ b/pkg/github/ui_resources_test.go @@ -0,0 +1,123 @@ +package github + +import ( + "context" + "testing" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRegisterUIResources_ReadableViaClient verifies that each UI resource URI +// advertised by an MCP App-enabled tool (e.g. issue_write, create_pull_request, +// get_me) actually resolves to a registered resource on the server. +// +// Regression test for the "Error loading MCP App: MPC -32002: Resource not +// found" bug reported in issue #2467, where the HTTP/remote server returned a +// resource URI in the tool's _meta.ui block but never registered the matching +// resource — so the follow-up resources/read call from the client failed. +func TestRegisterUIResources_ReadableViaClient(t *testing.T) { + t.Parallel() + + if !UIAssetsAvailable() { + t.Skip("UI assets not built; run script/build-ui to enable this test") + } + + srv := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil) + RegisterUIResources(srv) + + // Connect an in-memory client/server pair and read each advertised URI. + st, ct := mcp.NewInMemoryTransports() + + type clientResult struct { + session *mcp.ClientSession + err error + } + clientCh := make(chan clientResult, 1) + go func() { + client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil) + cs, err := client.Connect(context.Background(), ct, nil) + clientCh <- clientResult{session: cs, err: err} + }() + + ss, err := srv.Connect(context.Background(), st, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = ss.Close() }) + + got := <-clientCh + require.NoError(t, got.err) + t.Cleanup(func() { _ = got.session.Close() }) + + uris := []string{ + GetMeUIResourceURI, + IssueWriteUIResourceURI, + PullRequestWriteUIResourceURI, + } + for _, uri := range uris { + t.Run(uri, func(t *testing.T) { + res, err := got.session.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: uri}) + require.NoError(t, err, "resource %s should be registered (got -32002 means it isn't)", uri) + require.NotNil(t, res) + require.NotEmpty(t, res.Contents) + assert.Equal(t, uri, res.Contents[0].URI) + assert.Equal(t, MCPAppMIMEType, res.Contents[0].MIMEType) + assert.NotEmpty(t, res.Contents[0].Text, "UI resource should return HTML body") + }) + } +} + +// TestNewMCPServer_RegistersUIResources verifies that NewMCPServer — the +// shared constructor used by both the stdio and HTTP entry points — registers +// the UI resources when UI assets are embedded. Previously this registration +// only happened in the stdio bootstrap, so remote/HTTP clients hit -32002. +func TestNewMCPServer_RegistersUIResources(t *testing.T) { + t.Parallel() + + if !UIAssetsAvailable() { + t.Skip("UI assets not built; run script/build-ui to enable this test") + } + + srv, err := NewMCPServer(context.Background(), &MCPServerConfig{ + Version: "test", + Translator: stubTranslator, + }, stubDeps{t: stubTranslator}, mustEmptyInventory(t)) + require.NoError(t, err) + + st, ct := mcp.NewInMemoryTransports() + + type clientResult struct { + session *mcp.ClientSession + err error + } + clientCh := make(chan clientResult, 1) + go func() { + client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil) + cs, err := client.Connect(context.Background(), ct, nil) + clientCh <- clientResult{session: cs, err: err} + }() + + ss, err := srv.Connect(context.Background(), st, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = ss.Close() }) + + got := <-clientCh + require.NoError(t, got.err) + t.Cleanup(func() { _ = got.session.Close() }) + + res, err := got.session.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: IssueWriteUIResourceURI}) + require.NoError(t, err) + require.NotNil(t, res) + require.NotEmpty(t, res.Contents) + assert.Equal(t, MCPAppMIMEType, res.Contents[0].MIMEType) +} + +// mustEmptyInventory builds an empty inventory for tests that only care about +// resources/prompts registered outside the inventory (such as the UI resources). +func mustEmptyInventory(t *testing.T) *inventory.Inventory { + t.Helper() + inv, err := NewInventory(stubTranslator).WithToolsets([]string{}).Build() + require.NoError(t, err) + return inv +} diff --git a/pkg/http/handler.go b/pkg/http/handler.go 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..3c9d7679e4 --- /dev/null +++ b/pkg/http/server.go @@ -0,0 +1,254 @@ +package http + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/signal" + "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 + + // 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 := fmt.Sprintf(":%d", 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 +} + +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..62511775a9 --- /dev/null +++ b/pkg/http/server_test.go @@ -0,0 +1,134 @@ +package http + +import ( + "context" + "testing" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateHTTPFeatureChecker(t *testing.T) { + tests := []struct { + name string + staticFeatures []string + staticInsiders bool + flagName string + headerFeatures []string + insidersMode bool + wantEnabled bool + }{ + { + name: "allowed issues_granular flag accepted from header", + flagName: github.FeatureFlagIssuesGranular, + headerFeatures: []string{github.FeatureFlagIssuesGranular}, + wantEnabled: true, + }, + { + name: "allowed pull_requests_granular flag accepted from header", + flagName: github.FeatureFlagPullRequestsGranular, + headerFeatures: []string{github.FeatureFlagPullRequestsGranular}, + wantEnabled: true, + }, + { + name: "MCP Apps flag accepted from header", + flagName: github.MCPAppsFeatureFlag, + headerFeatures: []string{github.MCPAppsFeatureFlag}, + wantEnabled: true, + }, + { + name: "unknown flag in header is ignored", + flagName: "unknown_flag", + headerFeatures: []string{"unknown_flag"}, + wantEnabled: false, + }, + { + name: "allowed flag not in header returns false", + flagName: github.FeatureFlagIssuesGranular, + headerFeatures: nil, + wantEnabled: false, + }, + { + name: "allowed flag with different flag in header returns false", + flagName: github.FeatureFlagIssuesGranular, + headerFeatures: []string{github.FeatureFlagPullRequestsGranular}, + wantEnabled: false, + }, + { + name: "multiple allowed flags in header", + flagName: github.FeatureFlagIssuesGranular, + headerFeatures: []string{github.FeatureFlagIssuesGranular, github.FeatureFlagPullRequestsGranular}, + wantEnabled: true, + }, + { + name: "empty header features", + flagName: github.FeatureFlagIssuesGranular, + headerFeatures: []string{}, + wantEnabled: false, + }, + { + name: "insiders mode enables MCP Apps without header", + flagName: github.MCPAppsFeatureFlag, + insidersMode: true, + wantEnabled: true, + }, + { + name: "static feature is enabled without header", + staticFeatures: []string{github.FeatureFlagCSVOutput}, + flagName: github.FeatureFlagCSVOutput, + wantEnabled: true, + }, + { + name: "static features combine with header features", + staticFeatures: []string{github.FeatureFlagCSVOutput}, + flagName: github.FeatureFlagIssuesGranular, + headerFeatures: []string{github.FeatureFlagIssuesGranular}, + wantEnabled: true, + }, + { + name: "static insiders enables insiders flags without route context", + staticInsiders: true, + flagName: github.FeatureFlagCSVOutput, + wantEnabled: true, + }, + { + name: "insiders mode enables internal-only insiders flags", + flagName: github.FeatureFlagIFCLabels, + insidersMode: true, + wantEnabled: true, + }, + { + name: "insiders mode does not enable granular flags", + flagName: github.FeatureFlagIssuesGranular, + insidersMode: true, + wantEnabled: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checker := createHTTPFeatureChecker(tt.staticFeatures, tt.staticInsiders) + ctx := context.Background() + if len(tt.headerFeatures) > 0 { + ctx = ghcontext.WithHeaderFeatures(ctx, tt.headerFeatures) + } + if tt.insidersMode { + ctx = ghcontext.WithInsidersMode(ctx, true) + } + + enabled, err := checker(ctx, tt.flagName) + require.NoError(t, err) + assert.Equal(t, tt.wantEnabled, enabled) + }) + } +} + +func TestHeaderAllowedFeatureFlagsMatchesAllowed(t *testing.T) { + // Ensure HeaderAllowedFeatureFlags delegates to AllowedFeatureFlags + allowed := github.HeaderAllowedFeatureFlags() + assert.Equal(t, github.AllowedFeatureFlags, allowed, + "HeaderAllowedFeatureFlags() should match AllowedFeatureFlags") + assert.NotEmpty(t, allowed, "AllowedFeatureFlags should not be empty") +} diff --git a/pkg/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/http/transport/graphql_features.go b/pkg/http/transport/graphql_features.go new file mode 100644 index 0000000000..7fe9182fcb --- /dev/null +++ b/pkg/http/transport/graphql_features.go @@ -0,0 +1,52 @@ +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 +// header to requests based on context values. This is required for using +// non-GA GraphQL API features like the agent assignment API. +// +// This transport is used internally by the MCP server and is also exported +// for library consumers who need to build their own HTTP clients with +// GraphQL feature flag support. +// +// Usage: +// +// import "github.com/github/github-mcp-server/pkg/http/transport" +// +// httpClient := &http.Client{ +// Transport: &transport.GraphQLFeaturesTransport{ +// Transport: http.DefaultTransport, +// }, +// } +// gqlClient := githubv4.NewClient(httpClient) +// +// 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 +} + +// RoundTrip implements http.RoundTripper. +func (t *GraphQLFeaturesTransport) RoundTrip(req *http.Request) (*http.Response, error) { + transport := t.Transport + if transport == nil { + transport = http.DefaultTransport + } + + // Clone the request to avoid mutating the original + req = req.Clone(req.Context()) + + // Check for GraphQL-Features in context and add header if present + if features := ghcontext.GetGraphQLFeatures(req.Context()); len(features) > 0 { + req.Header.Set(headers.GraphQLFeaturesHeader, strings.Join(features, ", ")) + } + + return transport.RoundTrip(req) +} diff --git a/pkg/http/transport/graphql_features_test.go b/pkg/http/transport/graphql_features_test.go new file mode 100644 index 0000000000..1a0dc4214f --- /dev/null +++ b/pkg/http/transport/graphql_features_test.go @@ -0,0 +1,154 @@ +package transport + +import ( + "context" + "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/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGraphQLFeaturesTransport(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + features []string + expectedHeader string + hasHeader bool + }{ + { + name: "no features in context", + features: nil, + expectedHeader: "", + hasHeader: false, + }, + { + name: "single feature in context", + features: []string{"issues_copilot_assignment_api_support"}, + expectedHeader: "issues_copilot_assignment_api_support", + hasHeader: true, + }, + { + name: "multiple features in context", + features: []string{"feature1", "feature2", "feature3"}, + expectedHeader: "feature1, feature2, feature3", + hasHeader: true, + }, + { + name: "empty features slice", + features: []string{}, + expectedHeader: "", + hasHeader: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var capturedHeader string + var headerExists bool + + // Create a test server that captures the request header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeader = r.Header.Get(headers.GraphQLFeaturesHeader) + headerExists = r.Header.Get(headers.GraphQLFeaturesHeader) != "" + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Create the transport + transport := &GraphQLFeaturesTransport{ + Transport: http.DefaultTransport, + } + + // Create a request + ctx := context.Background() + if tc.features != nil { + ctx = ghcontext.WithGraphQLFeatures(ctx, tc.features...) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil) + require.NoError(t, err) + + // Execute the request + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Verify the header + assert.Equal(t, tc.hasHeader, headerExists) + if tc.hasHeader { + assert.Equal(t, tc.expectedHeader, capturedHeader) + } + }) + } +} + +func TestGraphQLFeaturesTransport_NilTransport(t *testing.T) { + t.Parallel() + + var capturedHeader string + + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeader = r.Header.Get(headers.GraphQLFeaturesHeader) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Create the transport with nil Transport (should use DefaultTransport) + transport := &GraphQLFeaturesTransport{ + Transport: nil, + } + + // Create a request with features + ctx := ghcontext.WithGraphQLFeatures(context.Background(), "test_feature") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil) + require.NoError(t, err) + + // Execute the request + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Verify the header was added + assert.Equal(t, "test_feature", capturedHeader) +} + +func TestGraphQLFeaturesTransport_DoesNotMutateOriginalRequest(t *testing.T) { + t.Parallel() + + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Create the transport + transport := &GraphQLFeaturesTransport{ + Transport: http.DefaultTransport, + } + + // Create a request with features + ctx := 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(headers.GraphQLFeaturesHeader) + + // Execute the request + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Verify the original request was not mutated + assert.Equal(t, originalHeader, req.Header.Get(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..e6eeb407bc --- /dev/null +++ b/pkg/ifc/ifc.go @@ -0,0 +1,108 @@ +// Package ifc provides Information Flow Control labels for annotating MCP tool outputs. +// The actual IFC enforcement engine lives in a separate service; this package only +// defines the label schema used for annotations. +package ifc + +type Integrity string + +const ( + IntegrityTrusted Integrity = "trusted" + IntegrityUntrusted Integrity = "untrusted" +) + +type Confidentiality string + +const ( + ConfidentialityPublic Confidentiality = "public" + ConfidentialityPrivate Confidentiality = "private" +) + +type SecurityLabel struct { + Integrity Integrity `json:"integrity"` + Confidentiality Confidentiality `json:"confidentiality"` +} + +// PublicTrusted returns a label for trusted, publicly readable data. +func PublicTrusted() SecurityLabel { + return SecurityLabel{ + Integrity: IntegrityTrusted, + Confidentiality: ConfidentialityPublic, + } +} + +// PublicUntrusted returns a label for untrusted, publicly readable data. +func PublicUntrusted() SecurityLabel { + return SecurityLabel{ + Integrity: IntegrityUntrusted, + Confidentiality: ConfidentialityPublic, + } +} + +// PrivateTrusted returns a label for trusted data restricted to the readers +// of the originating repository. The reader set is opaque on the wire (a +// single "private" marker); the client engine resolves the concrete readers +// from the GitHub API on demand at egress decision time. +func PrivateTrusted() SecurityLabel { + return SecurityLabel{ + Integrity: IntegrityTrusted, + Confidentiality: ConfidentialityPrivate, + } +} + +// PrivateUntrusted returns a label for untrusted data restricted to the +// readers of the originating repository. See PrivateTrusted for the reader +// resolution model. +func PrivateUntrusted() SecurityLabel { + return SecurityLabel{ + Integrity: IntegrityUntrusted, + Confidentiality: ConfidentialityPrivate, + } +} + +func LabelGetMe() SecurityLabel { + return PublicTrusted() +} + +// LabelListIssues returns the IFC label for a list_issues result. +// Public repositories are universally readable; private repositories are +// restricted to their collaborators (resolved client-side from the marker). +// Issue contents are attacker-controllable, so integrity is always untrusted. +func LabelListIssues(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateUntrusted() + } + return PublicUntrusted() +} + +// LabelGetFileContents returns the IFC label for a get_file_contents result. +// Public repository file contents may be authored by anyone via pull requests +// and are therefore untrusted. In private repositories only collaborators can +// land changes, so contents are treated as trusted. +func LabelGetFileContents(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateTrusted() + } + return PublicUntrusted() +} + +// LabelSearchIssues returns the IFC label for a multi-repository search +// result, joining per-repository labels across all matched repositories. +// Used by both search_issues and search_repositories. +// +// Integrity is always untrusted because results expose user-authored content. +// +// Confidentiality follows the IFC meet (greatest lower bound): if any matched +// repository is private the joined label is private; otherwise public. The +// reader set is opaque (the "private" marker); the client engine resolves +// concrete readers on demand at egress decision time. +// +// An empty result set is treated as public-untrusted (no repository data is +// leaked). +func LabelSearchIssues(repoVisibilities []bool) SecurityLabel { + for _, isPrivate := range repoVisibilities { + if isPrivate { + return PrivateUntrusted() + } + } + return PublicUntrusted() +} diff --git a/pkg/ifc/ifc_test.go b/pkg/ifc/ifc_test.go new file mode 100644 index 0000000000..669f5ff0cc --- /dev/null +++ b/pkg/ifc/ifc_test.go @@ -0,0 +1,51 @@ +package ifc + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLabelSearchIssues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + visibilities []bool + wantConfidential Confidentiality + }{ + { + name: "empty result is treated as public", + wantConfidential: ConfidentialityPublic, + }, + { + name: "single public repo", + visibilities: []bool{false}, + wantConfidential: ConfidentialityPublic, + }, + { + name: "all public repos stay public", + visibilities: []bool{false, false, false}, + wantConfidential: ConfidentialityPublic, + }, + { + name: "any private match flips to private", + visibilities: []bool{false, true, false}, + wantConfidential: ConfidentialityPrivate, + }, + { + name: "all private repos stay private", + visibilities: []bool{true, true}, + wantConfidential: ConfidentialityPrivate, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + label := LabelSearchIssues(tc.visibilities) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, tc.wantConfidential, label.Confidentiality) + }) + } +} diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index a0ed2baee3..9ecaca1f57 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -2,10 +2,23 @@ package inventory import ( "context" - "sort" + "errors" + "fmt" + "maps" + "slices" "strings" ) +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) @@ -33,12 +46,13 @@ type Builder struct { deprecatedAliases map[string]string // Configuration options (processed at Build time) - readOnly bool - toolsetIDs []string // raw input, processed at Build() - toolsetIDsIsNil bool // tracks if nil was passed (nil = defaults) - additionalTools []string // raw input, processed at Build() - featureChecker FeatureFlagChecker - filters []ToolFilter // filters to apply to all tools + readOnly bool + toolsetIDs []string // raw input, processed at Build() + toolsetIDsIsNil bool // tracks if nil was passed (nil = defaults) + additionalTools []string // raw input, processed at Build() + featureChecker FeatureFlagChecker + filters []ToolFilter // filters to apply to all tools + generateInstructions bool } // NewBuilder creates a new Builder. @@ -70,9 +84,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 } @@ -83,14 +95,18 @@ func (b *Builder) WithReadOnly(readOnly bool) *Builder { return b } +func (b *Builder) WithServerInstructions() *Builder { + b.generateInstructions = true + return b +} + // WithToolsets specifies which toolsets should be enabled. // Special keywords: // - "all": enables all toolsets // - "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 @@ -101,6 +117,7 @@ func (b *Builder) WithToolsets(toolsetIDs []string) *Builder { // WithTools specifies additional tools that bypass toolset filtering. // These tools are additive - they will be included even if their toolset is not enabled. // Read-only filtering still applies to these tools. +// Input is cleaned (trimmed, deduplicated) during Build(). // Deprecated tool aliases are automatically resolved to their canonical names during Build(). // Returns self for chaining. func (b *Builder) WithTools(toolNames []string) *Builder { @@ -110,8 +127,20 @@ func (b *Builder) WithTools(toolNames []string) *Builder { // WithFeatureChecker sets the feature flag checker function. // The checker receives a context (for actor extraction) and feature flag name, -// returns (enabled, error). If error occurs, it will be logged and treated as false. -// If checker is nil, all feature flag checks return false. +// and returns (enabled, error). Errors are logged and treated as "not enabled". +// +// When the checker is non-nil, Build() installs a feature-flag ToolFilter +// at the head of the filter pipeline so that tools annotated with +// FeatureFlagEnable / FeatureFlagDisable are gated accordingly. Resources +// and prompts use the same checker via an explicit guard at their iteration +// site. +// +// When the checker is nil, no feature-flag filter is installed; tools, +// resources, and prompts pass through feature-flag gating unchanged. The +// per-request inventory in HTTP mode must always install a checker so that +// MCP registration (which can only serve a given tool name once) sees a +// deduplicated set of dual-name variants. +// // Returns self for chaining. func (b *Builder) WithFeatureChecker(checker FeatureFlagChecker) *Builder { b.featureChecker = checker @@ -127,38 +156,123 @@ 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 { + seen := make(map[string]bool) + var cleaned []string + for _, name := range tools { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + continue + } + if !seen[trimmed] { + seen[trimmed] = true + cleaned = append(cleaned, trimmed) + } + } + return cleaned +} + // Build creates the final Inventory with all configuration applied. // This processes toolset filtering, tool name resolution, and sets up // the inventory for use. The returned Inventory is ready for use with // AvailableTools(), RegisterAll(), etc. -func (b *Builder) Build() *Inventory { +// +// Build returns an error if any tools specified via WithTools() are not recognized +// (i.e., they don't exist in the tool set and are not deprecated aliases). +// This ensures invalid tool configurations fail fast at build time. +func (b *Builder) Build() (*Inventory, error) { + 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() - // Process additional tools (resolve aliases) + // Build set of valid tool names for validation + 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) if len(b.additionalTools) > 0 { - r.additionalTools = make(map[string]bool, len(b.additionalTools)) - for _, name := range b.additionalTools { - // Resolve deprecated aliases to canonical names + cleanedTools := cleanTools(b.additionalTools) + + r.additionalTools = make(map[string]bool, len(cleanedTools)) + var unrecognizedTools []string + for _, name := range cleanedTools { + // Always include the original name - this handles the case where + // the tool exists but is controlled by a feature flag that's OFF. + r.additionalTools[name] = true + // Also include the canonical name if this is a deprecated alias. + // This handles the case where the feature flag is ON and only + // the new consolidated tool is available. if canonical, isAlias := b.deprecatedAliases[name]; isAlias { r.additionalTools[canonical] = true - } else { - r.additionalTools[name] = true + } else if !validToolNames[name] { + // Not a valid tool and not a deprecated alias - track as unrecognized + unrecognizedTools = append(unrecognizedTools, name) } } + + // Error out if there are unrecognized tools + if len(unrecognizedTools) > 0 { + return nil, fmt.Errorf("%w: %s", ErrUnknownTools, strings.Join(unrecognizedTools, ", ")) + } } - return r + if b.generateInstructions { + r.instructions = generateInstructions(r) + } + + return r, nil } // processToolsets processes the toolsetIDs configuration and returns: @@ -210,13 +324,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 @@ -272,3 +386,58 @@ 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 +} + +// 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 c5156e61a1..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 } @@ -203,17 +248,6 @@ func (r *Inventory) filterToolsByName(name string) []ServerTool { return result } -// filterResourcesByURI returns resource templates matching the given URI pattern. -// Uses linear scan - optimized for single-lookup per-request scenarios (ForMCPRequest). -func (r *Inventory) filterResourcesByURI(uri string) []ServerResourceTemplate { - for i := range r.resourceTemplates { - if r.resourceTemplates[i].Template.URITemplate == uri { - return []ServerResourceTemplate{r.resourceTemplates[i]} - } - } - return []ServerResourceTemplate{} -} - // filterPromptsByName returns prompts matching the given name. // Uses linear scan - optimized for single-lookup per-request scenarios (ForMCPRequest). func (r *Inventory) filterPromptsByName(name string) []ServerPrompt { @@ -225,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/instructions.go b/pkg/inventory/instructions.go new file mode 100644 index 0000000000..02e90cd200 --- /dev/null +++ b/pkg/inventory/instructions.go @@ -0,0 +1,43 @@ +package inventory + +import ( + "os" + "strings" +) + +// generateInstructions creates server instructions based on enabled toolsets +func generateInstructions(inv *Inventory) string { + // For testing - add a flag to disable instructions + if os.Getenv("DISABLE_INSTRUCTIONS") == "true" { + return "" // Baseline mode + } + + var instructions []string + + // Base instruction with context management + baseInstruction := `The GitHub MCP Server provides tools to interact with GitHub platform. + +Tool selection guidance: + 1. Use 'list_*' tools for broad, simple retrieval and pagination of all items of a type (e.g., all issues, all PRs, all branches) with basic filtering. + 2. Use 'search_*' tools for targeted queries with specific criteria, keywords, or complex filters (e.g., issues with certain text, PRs by author, code containing functions). + +Context management: + 1. Use pagination whenever possible with batches of 5-10 items. + 2. Use minimal_output parameter set to true if the full information is not needed to accomplish a task. + +Tool usage guidance: + 1. For 'search_*' tools: Use separate 'sort' and 'order' parameters if available for sorting results - do not include 'sort:' syntax in query strings. Query strings should contain only search criteria (e.g., 'org:google language:python'), not sorting instructions.` + + instructions = append(instructions, baseInstruction) + + // Collect instructions from each enabled toolset + for _, toolset := range inv.EnabledToolsets() { + if toolset.InstructionsFunc != nil { + if toolsetInstructions := toolset.InstructionsFunc(inv); toolsetInstructions != "" { + instructions = append(instructions, toolsetInstructions) + } + } + } + + return strings.Join(instructions, " ") +} diff --git a/pkg/inventory/instructions_test.go b/pkg/inventory/instructions_test.go new file mode 100644 index 0000000000..e8e369b3db --- /dev/null +++ b/pkg/inventory/instructions_test.go @@ -0,0 +1,265 @@ +package inventory + +import ( + "os" + "strings" + "testing" +) + +// createTestInventory creates an inventory with the specified toolsets for testing. +// All toolsets are enabled by default using WithToolsets([]string{"all"}). +func createTestInventory(toolsets []ToolsetMetadata) *Inventory { + // Create tools for each toolset so they show up in AvailableToolsets() + var tools []ServerTool + for _, ts := range toolsets { + tools = append(tools, ServerTool{ + Toolset: ts, + }) + } + + inv, _ := NewBuilder(). + SetTools(tools). + WithToolsets([]string{"all"}). + Build() + + return inv +} + +func TestGenerateInstructions(t *testing.T) { + tests := []struct { + name string + toolsets []ToolsetMetadata + expectedEmpty bool + }{ + { + name: "empty toolsets", + toolsets: []ToolsetMetadata{}, + expectedEmpty: false, // base instructions are always included + }, + { + name: "toolset with instructions", + toolsets: []ToolsetMetadata{ + { + ID: "test", + Description: "Test toolset", + InstructionsFunc: func(_ *Inventory) string { + return "Test instructions" + }, + }, + }, + expectedEmpty: false, + }, + { + name: "toolset without instructions", + toolsets: []ToolsetMetadata{ + { + ID: "test", + Description: "Test toolset", + }, + }, + expectedEmpty: false, // base instructions still included + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inv := createTestInventory(tt.toolsets) + result := generateInstructions(inv) + + if tt.expectedEmpty { + if result != "" { + t.Errorf("Expected empty instructions but got: %s", result) + } + } else { + if result == "" { + t.Errorf("Expected non-empty instructions but got empty result") + } + } + }) + } +} + +func TestGenerateInstructionsWithDisableFlag(t *testing.T) { + tests := []struct { + name string + disableEnvValue string + expectedEmpty bool + }{ + { + name: "DISABLE_INSTRUCTIONS=true returns empty", + disableEnvValue: "true", + expectedEmpty: true, + }, + { + name: "DISABLE_INSTRUCTIONS=false returns normal instructions", + disableEnvValue: "false", + expectedEmpty: false, + }, + { + name: "DISABLE_INSTRUCTIONS unset returns normal instructions", + disableEnvValue: "", + expectedEmpty: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save original env value + originalValue := os.Getenv("DISABLE_INSTRUCTIONS") + defer func() { + if originalValue == "" { + os.Unsetenv("DISABLE_INSTRUCTIONS") + } else { + os.Setenv("DISABLE_INSTRUCTIONS", originalValue) + } + }() + + // Set test env value + if tt.disableEnvValue == "" { + os.Unsetenv("DISABLE_INSTRUCTIONS") + } else { + os.Setenv("DISABLE_INSTRUCTIONS", tt.disableEnvValue) + } + + inv := createTestInventory([]ToolsetMetadata{ + {ID: "test", Description: "Test"}, + }) + result := generateInstructions(inv) + + if tt.expectedEmpty { + if result != "" { + t.Errorf("Expected empty instructions but got: %s", result) + } + } else { + if result == "" { + t.Errorf("Expected non-empty instructions but got empty result") + } + } + }) + } +} + +func TestToolsetInstructionsFunc(t *testing.T) { + tests := []struct { + name string + toolsets []ToolsetMetadata + expectedToContain string + notExpectedToContain string + }{ + { + name: "toolset with context-aware instructions includes extra text when dependency present", + toolsets: []ToolsetMetadata{ + {ID: "repos", Description: "Repos"}, + { + ID: "pull_requests", + Description: "PRs", + InstructionsFunc: func(inv *Inventory) string { + instructions := "PR base instructions" + if inv.HasToolset("repos") { + instructions += " PR template instructions" + } + return instructions + }, + }, + }, + expectedToContain: "PR template instructions", + }, + { + name: "toolset with context-aware instructions excludes extra text when dependency missing", + toolsets: []ToolsetMetadata{ + { + ID: "pull_requests", + Description: "PRs", + InstructionsFunc: func(inv *Inventory) string { + instructions := "PR base instructions" + if inv.HasToolset("repos") { + instructions += " PR template instructions" + } + return instructions + }, + }, + }, + notExpectedToContain: "PR template instructions", + }, + { + name: "toolset without InstructionsFunc returns no toolset-specific instructions", + toolsets: []ToolsetMetadata{ + {ID: "test", Description: "Test without instructions"}, + }, + notExpectedToContain: "## Test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inv := createTestInventory(tt.toolsets) + result := generateInstructions(inv) + + if tt.expectedToContain != "" && !strings.Contains(result, tt.expectedToContain) { + t.Errorf("Expected result to contain '%s', but it did not. Result: %s", tt.expectedToContain, result) + } + + if tt.notExpectedToContain != "" && strings.Contains(result, tt.notExpectedToContain) { + t.Errorf("Did not expect result to contain '%s', but it did. Result: %s", tt.notExpectedToContain, result) + } + }) + } +} + +// TestGenerateInstructionsOnlyEnabledToolsets verifies that generateInstructions +// only includes instructions from enabled toolsets, not all available toolsets. +// This is a regression test for https://github.com/github/github-mcp-server/issues/1897 +func TestGenerateInstructionsOnlyEnabledToolsets(t *testing.T) { + // Create tools for multiple toolsets + reposToolset := ToolsetMetadata{ + ID: "repos", + Description: "Repository tools", + InstructionsFunc: func(_ *Inventory) string { + return "REPOS_INSTRUCTIONS" + }, + } + issuesToolset := ToolsetMetadata{ + ID: "issues", + Description: "Issue tools", + InstructionsFunc: func(_ *Inventory) string { + return "ISSUES_INSTRUCTIONS" + }, + } + prsToolset := ToolsetMetadata{ + ID: "pull_requests", + Description: "PR tools", + InstructionsFunc: func(_ *Inventory) string { + return "PRS_INSTRUCTIONS" + }, + } + + tools := []ServerTool{ + {Toolset: reposToolset}, + {Toolset: issuesToolset}, + {Toolset: prsToolset}, + } + + // Build inventory with only "repos" toolset enabled + inv, err := NewBuilder(). + SetTools(tools). + WithToolsets([]string{"repos"}). + Build() + if err != nil { + t.Fatalf("Failed to build inventory: %v", err) + } + + result := generateInstructions(inv) + + // Should contain instructions from enabled toolset + if !strings.Contains(result, "REPOS_INSTRUCTIONS") { + t.Errorf("Expected instructions to contain 'REPOS_INSTRUCTIONS' for enabled toolset, but it did not. Result: %s", result) + } + + // Should NOT contain instructions from non-enabled toolsets + if strings.Contains(result, "ISSUES_INSTRUCTIONS") { + t.Errorf("Did not expect instructions to contain 'ISSUES_INSTRUCTIONS' for disabled toolset, but it did. Result: %s", result) + } + if strings.Contains(result, "PRS_INSTRUCTIONS") { + t.Errorf("Did not expect instructions to contain 'PRS_INSTRUCTIONS' for disabled toolset, but it did. Result: %s", result) + } +} diff --git a/pkg/inventory/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 f3691e38ab..b8a70a3420 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -7,6 +7,7 @@ import ( "slices" "sort" + ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -23,7 +24,6 @@ import ( // - Filtered access to tools/resources/prompts via Available* methods // - Deterministic ordering for documentation generation // - Lazy dependency injection during registration via RegisterAll() -// - Runtime toolset enabling for dynamic toolsets mode type Inventory struct { // tools holds all tools in this group (ordered for iteration) tools []ServerTool @@ -58,6 +58,8 @@ type Inventory struct { filters []ToolFilter // unrecognizedToolsets holds toolset IDs that were requested but don't match any registered toolsets unrecognizedToolsets []string + // server instructions hold high-level instructions for agents to use the server effectively + instructions string } // UnrecognizedToolsets returns toolset IDs that were passed to WithToolsets but don't @@ -91,7 +93,7 @@ const ( // - MCPMethodToolsList: All available tools (no resources/prompts) // - MCPMethodToolsCall: Only the named tool // - MCPMethodResourcesList, MCPMethodResourcesTemplatesList: All available resources (no tools/prompts) -// - MCPMethodResourcesRead: Only the named resource template +// - MCPMethodResourcesRead: All resources (SDK handles URI template matching) // - MCPMethodPromptsList: All available prompts (no tools/resources) // - MCPMethodPromptsGet: Only the named prompt // - Unknown methods: Empty (no items registered) @@ -134,10 +136,8 @@ func (r *Inventory) ForMCPRequest(method string, itemName string) *Inventory { case MCPMethodResourcesList, MCPMethodResourcesTemplatesList: result.tools, result.prompts = nil, nil case MCPMethodResourcesRead: + // Keep all resources registered - SDK handles URI template matching internally result.tools, result.prompts = nil, nil - if itemName != "" { - result.resourceTemplates = r.filterResourcesByURI(itemName) - } case MCPMethodPromptsList: result.tools, result.resourceTemplates = nil, nil case MCPMethodPromptsGet: @@ -168,10 +168,54 @@ func (r *Inventory) ToolsetDescriptions() map[ToolsetID]string { return r.toolsetDescriptions } +// ToolsForRegistration returns AvailableTools(ctx) post-processed exactly as +// RegisterTools would expose them: with MCP Apps UI metadata stripped when +// the client cannot consume it. Useful for documentation generators and +// diagnostics that need the same view of the tool surface the server would +// register. +// +// The strip applies when EITHER of the following is true: +// +// - The remote_mcp_ui_apps feature flag is not enabled in ctx (server-side gate). +// - The client explicitly did not advertise the io.modelcontextprotocol/ui +// extension capability (per the 2026-01-26 MCP Apps spec, servers SHOULD +// check client capabilities before exposing UI-enabled tools). When the +// capability is unknown (e.g. stdio paths that do not populate the +// context flag) the feature-flag gate is the sole source of truth. +func (r *Inventory) ToolsForRegistration(ctx context.Context) []ServerTool { + tools := r.AvailableTools(ctx) + if shouldStripMCPAppsMetadata(ctx, r.checkFeatureFlag(ctx, mcpAppsFeatureFlag)) { + tools = stripMCPAppsMetadata(tools) + } + return tools +} + +// shouldStripMCPAppsMetadata centralises the strip decision so the same logic +// is exercised by tests and by RegisterTools. +func shouldStripMCPAppsMetadata(ctx context.Context, featureFlagEnabled bool) bool { + if !featureFlagEnabled { + return true + } + // Feature flag is on. Respect the client capability if it is known. + if supported, ok := ghcontext.HasUISupport(ctx); ok && !supported { + return true + } + return false +} + // RegisterTools registers all available tools with the server using the provided dependencies. -// The context is used for feature flag evaluation. +// The context is used for feature flag evaluation and client capability checks. +// +// MCP Apps UI metadata (`_meta.ui`) is stripped from the registered tools +// when either the MCP Apps feature flag is not enabled for this request, or +// the client did not advertise the io.modelcontextprotocol/ui extension. The +// strip happens here (rather than at Build() time) so the per-request +// context is in scope — HTTP feature checkers that read insiders mode or +// user identity from ctx would otherwise see context.Background() and +// falsely report the flag off, even when the actual request arrived on the +// /insiders route. func (r *Inventory) RegisterTools(ctx context.Context, s *mcp.Server, deps any) { - for _, tool := range r.AvailableTools(ctx) { + for _, tool := range r.ToolsForRegistration(ctx) { tool.RegisterFunc(s, deps) } } @@ -294,3 +338,29 @@ func (r *Inventory) AvailableToolsets(exclude ...ToolsetID) []ToolsetMetadata { } return result } + +// EnabledToolsets returns the unique toolsets that are enabled based on current filters. +// This is similar to AvailableToolsets but respects the enabledToolsets filter. +// Returns toolsets in sorted order by toolset ID. +func (r *Inventory) EnabledToolsets() []ToolsetMetadata { + // Get all available toolsets first (already sorted by ID) + allToolsets := r.AvailableToolsets() + + // If no filter is set, all toolsets are enabled + if r.enabledToolsets == nil { + return allToolsets + } + + // Filter to only enabled toolsets + var result []ToolsetMetadata + for _, ts := range allToolsets { + if r.enabledToolsets[ts.ID] { + result = append(result, ts) + } + } + return result +} + +func (r *Inventory) Instructions() string { + return r.instructions +} diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 742ad36469..20b1fb718c 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -6,9 +6,20 @@ import ( "fmt" "testing" + ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" ) +// mustBuild is a test helper that calls Build() and fails the test if an error occurs. +// Use this for tests where Build() is not expected to fail. +func mustBuild(t *testing.T, b *Builder) *Inventory { + t.Helper() + inv, err := b.Build() + require.NoError(t, err) + return inv +} + // testToolsetMetadata returns a ToolsetMetadata for testing func testToolsetMetadata(id string) ToolsetMetadata { return ToolsetMetadata{ @@ -28,7 +39,7 @@ func testToolsetMetadataWithDefault(id string, isDefault bool) ToolsetMetadata { // mockToolWithDefault creates a mock tool with a default toolset flag func mockToolWithDefault(name string, toolsetID string, readOnly bool, isDefault bool) ServerTool { - return NewServerToolFromHandler( + return NewServerTool( mcp.Tool{ Name: name, Annotations: &mcp.ToolAnnotations{ @@ -37,17 +48,15 @@ func mockToolWithDefault(name string, toolsetID string, readOnly bool, isDefault InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), }, testToolsetMetadataWithDefault(toolsetID, isDefault), - func(_ any) mcp.ToolHandler { - return func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return nil, nil - } + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil }, ) } // mockTool creates a minimal ServerTool for testing func mockTool(name string, toolsetID string, readOnly bool) ServerTool { - return NewServerToolFromHandler( + return NewServerTool( mcp.Tool{ Name: name, Annotations: &mcp.ToolAnnotations{ @@ -56,16 +65,14 @@ 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 }, ) } func TestNewRegistryEmpty(t *testing.T) { - reg := NewBuilder().Build() + reg := mustBuild(t, NewBuilder()) if len(reg.AvailableTools(context.Background())) != 0 { t.Fatalf("Expected tools to be empty") } @@ -84,7 +91,7 @@ func TestNewRegistryWithTools(t *testing.T) { mockTool("tool3", "toolset2", true), } - reg := NewBuilder().SetTools(tools).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools)) if len(reg.AllTools()) != 3 { t.Errorf("Expected 3 tools, got %d", len(reg.AllTools())) @@ -98,7 +105,7 @@ func TestAvailableTools_NoFilters(t *testing.T) { mockTool("tool_c", "toolset2", true), } - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) available := reg.AvailableTools(context.Background()) if len(available) != 3 { @@ -121,14 +128,14 @@ func TestWithReadOnly(t *testing.T) { } // Build without read-only - should have both tools - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) allTools := reg.AvailableTools(context.Background()) if len(allTools) != 2 { t.Fatalf("Expected 2 tools without read-only, got %d", len(allTools)) } // Build with read-only - should filter out write tools - readOnlyReg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true).Build() + readOnlyReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true)) readOnlyTools := readOnlyReg.AvailableTools(context.Background()) if len(readOnlyTools) != 1 { t.Fatalf("Expected 1 tool in read-only, got %d", len(readOnlyTools)) @@ -146,14 +153,14 @@ func TestWithToolsets(t *testing.T) { } // Build with all toolsets - allReg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + allReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) allTools := allReg.AvailableTools(context.Background()) if len(allTools) != 3 { t.Fatalf("Expected 3 tools without filter, got %d", len(allTools)) } // Build with specific toolsets - filteredReg := NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1", "toolset3"}).Build() + filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1", "toolset3"})) filteredTools := filteredReg.AvailableTools(context.Background()) if len(filteredTools) != 2 { @@ -177,7 +184,7 @@ func TestWithToolsetsTrimsWhitespace(t *testing.T) { } // Whitespace should be trimmed - filteredReg := NewBuilder().SetTools(tools).WithToolsets([]string{" toolset1 ", " toolset2 "}).Build() + filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{" toolset1 ", " toolset2 "})) filteredTools := filteredReg.AvailableTools(context.Background()) if len(filteredTools) != 2 { @@ -191,7 +198,7 @@ func TestWithToolsetsDeduplicates(t *testing.T) { } // Duplicates should be removed - filteredReg := NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1", "toolset1", " toolset1 "}).Build() + filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1", "toolset1", " toolset1 "})) filteredTools := filteredReg.AvailableTools(context.Background()) if len(filteredTools) != 1 { @@ -205,7 +212,7 @@ func TestWithToolsetsIgnoresEmptyStrings(t *testing.T) { } // Empty strings should be ignored - filteredReg := NewBuilder().SetTools(tools).WithToolsets([]string{"", "toolset1", " ", ""}).Build() + filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"", "toolset1", " ", ""})) filteredTools := filteredReg.AvailableTools(context.Background()) if len(filteredTools) != 1 { @@ -253,7 +260,7 @@ func TestUnrecognizedToolsets(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - filtered := NewBuilder().SetTools(tools).WithToolsets(tt.input).Build() + filtered := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets(tt.input)) unrecognized := filtered.UnrecognizedToolsets() if len(unrecognized) != len(tt.expectedUnrecognized) { @@ -270,6 +277,109 @@ func TestUnrecognizedToolsets(t *testing.T) { } } +func TestBuildErrorsOnUnrecognizedTools(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), + mockTool("tool2", "toolset2", true), + } + + deprecatedAliases := map[string]string{ + "old_tool": "tool1", + } + + tests := []struct { + name string + withTools []string + expectError bool + errorContains string + }{ + { + name: "all valid", + withTools: []string{"tool1", "tool2"}, + expectError: false, + }, + { + name: "one invalid", + withTools: []string{"tool1", "blabla"}, + expectError: true, + errorContains: "blabla", + }, + { + name: "multiple invalid", + withTools: []string{"invalid1", "tool1", "invalid2"}, + expectError: true, + errorContains: "invalid1", + }, + { + name: "deprecated alias is valid", + withTools: []string{"old_tool"}, + expectError: false, + }, + { + name: "mixed valid and deprecated alias", + withTools: []string{"old_tool", "tool2"}, + expectError: false, + }, + { + name: "empty input", + withTools: []string{}, + expectError: false, + }, + { + name: "whitespace trimmed from valid tool", + withTools: []string{" tool1 ", " tool2 "}, + expectError: false, + }, + { + name: "whitespace trimmed from invalid tool", + withTools: []string{" invalid_tool "}, + expectError: true, + errorContains: "invalid_tool", + }, + { + name: "duplicate tools deduplicated", + withTools: []string{"tool1", "tool1"}, + expectError: false, + }, + { + name: "duplicate invalid tools deduplicated", + withTools: []string{"blabla", "blabla"}, + expectError: true, + errorContains: "blabla", + }, + { + name: "mixed whitespace and duplicates", + withTools: []string{" tool1 ", "tool1", " tool1 "}, + expectError: false, + }, + { + name: "empty strings ignored", + withTools: []string{"", "tool1", " ", ""}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inv, err := NewBuilder(). + SetTools(tools). + WithDeprecatedAliases(deprecatedAliases). + WithToolsets([]string{"all"}). + WithTools(tt.withTools). + Build() + + if tt.expectError { + require.Error(t, err, "Expected error for unrecognized tools") + require.Contains(t, err.Error(), tt.errorContains) + require.Nil(t, inv) + } else { + require.NoError(t, err) + require.NotNil(t, inv) + } + }) + } +} + func TestWithTools(t *testing.T) { tools := []ServerTool{ mockTool("tool1", "toolset1", true), @@ -279,7 +389,7 @@ func TestWithTools(t *testing.T) { // WithTools adds additional tools that bypass toolset filtering // When combined with WithToolsets([]), only the additional tools should be available - filteredReg := NewBuilder().SetTools(tools).WithToolsets([]string{}).WithTools([]string{"tool1", "tool3"}).Build() + filteredReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{}).WithTools([]string{"tool1", "tool3"})) filteredTools := filteredReg.AvailableTools(context.Background()) if len(filteredTools) != 2 { @@ -304,7 +414,7 @@ func TestChainedFilters(t *testing.T) { } // Chain read-only and toolset filter - filtered := NewBuilder().SetTools(tools).WithReadOnly(true).WithToolsets([]string{"toolset1"}).Build() + filtered := mustBuild(t, NewBuilder().SetTools(tools).WithReadOnly(true).WithToolsets([]string{"toolset1"})) result := filtered.AvailableTools(context.Background()) if len(result) != 1 { @@ -322,7 +432,7 @@ func TestToolsetIDs(t *testing.T) { mockTool("tool3", "toolset_b", true), // duplicate toolset } - reg := NewBuilder().SetTools(tools).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools)) ids := reg.ToolsetIDs() if len(ids) != 2 { @@ -341,7 +451,7 @@ func TestToolsetDescriptions(t *testing.T) { mockTool("tool2", "toolset2", true), } - reg := NewBuilder().SetTools(tools).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools)) descriptions := reg.ToolsetDescriptions() if len(descriptions) != 2 { @@ -353,30 +463,15 @@ 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 := NewBuilder().SetTools(tools).Build() - 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), } - reg := NewBuilder().SetTools(tools).WithDeprecatedAliases(map[string]string{ + reg := mustBuild(t, NewBuilder().SetTools(tools).WithDeprecatedAliases(map[string]string{ "old_name": "new_name", "get_issue": "issue_read", - }).Build() + })) // Test resolving aliases resolved, aliasesUsed := reg.ResolveToolAliases([]string{"old_name"}) @@ -394,10 +489,10 @@ func TestResolveToolAliases(t *testing.T) { mockTool("some_tool", "toolset1", true), } - reg := NewBuilder().SetTools(tools). + reg := mustBuild(t, NewBuilder().SetTools(tools). WithDeprecatedAliases(map[string]string{ "get_issue": "issue_read", - }).Build() + })) // Test resolving a mix of aliases and canonical names input := []string{"get_issue", "some_tool"} @@ -426,7 +521,7 @@ func TestFindToolByName(t *testing.T) { mockTool("issue_read", "toolset1", true), } - reg := NewBuilder().SetTools(tools).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools)) // Find by name tool, toolsetID, err := reg.FindToolByName("issue_read") @@ -456,7 +551,7 @@ func TestWithToolsAdditive(t *testing.T) { // Test WithTools bypasses toolset filtering // Enable only toolset2, but add issue_read as additional tool - filtered := NewBuilder().SetTools(tools).WithToolsets([]string{"toolset2"}).WithTools([]string{"issue_read"}).Build() + filtered := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"toolset2"}).WithTools([]string{"issue_read"})) available := filtered.AvailableTools(context.Background()) if len(available) != 2 { @@ -476,7 +571,7 @@ func TestWithToolsAdditive(t *testing.T) { } // Test WithTools respects read-only mode - readOnlyFiltered := NewBuilder().SetTools(tools).WithReadOnly(true).WithTools([]string{"issue_write"}).Build() + readOnlyFiltered := mustBuild(t, NewBuilder().SetTools(tools).WithReadOnly(true).WithTools([]string{"issue_write"})) available = readOnlyFiltered.AvailableTools(context.Background()) // issue_write should be excluded because read-only applies to additional tools too @@ -486,12 +581,10 @@ func TestWithToolsAdditive(t *testing.T) { } } - // Test WithTools with non-existent tool (should not error, just won't match anything) - nonexistent := NewBuilder().SetTools(tools).WithToolsets([]string{}).WithTools([]string{"nonexistent"}).Build() - available = nonexistent.AvailableTools(context.Background()) - if len(available) != 0 { - t.Errorf("expected 0 tools for non-existent additional tool, got %d", len(available)) - } + // Test WithTools with non-existent tool (should error during Build) + _, err := NewBuilder().SetTools(tools).WithToolsets([]string{}).WithTools([]string{"nonexistent"}).Build() + require.Error(t, err, "expected error for non-existent tool") + require.Contains(t, err.Error(), "nonexistent") } func TestWithToolsResolvesAliases(t *testing.T) { @@ -500,13 +593,12 @@ func TestWithToolsResolvesAliases(t *testing.T) { } // Using deprecated alias should resolve to canonical name - filtered := NewBuilder().SetTools(tools). + filtered := mustBuild(t, NewBuilder().SetTools(tools). WithDeprecatedAliases(map[string]string{ "get_issue": "issue_read", }). WithToolsets([]string{}). - WithTools([]string{"get_issue"}). - Build() + WithTools([]string{"get_issue"})) available := filtered.AvailableTools(context.Background()) if len(available) != 1 { @@ -522,7 +614,7 @@ func TestHasToolset(t *testing.T) { mockTool("tool1", "toolset1", true), } - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) if !reg.HasToolset("toolset1") { t.Error("expected HasToolset to return true for existing toolset") @@ -532,30 +624,6 @@ func TestHasToolset(t *testing.T) { } } -func TestEnabledToolsetIDs(t *testing.T) { - tools := []ServerTool{ - mockTool("tool1", "toolset1", true), - mockTool("tool2", "toolset2", true), - } - - // Without filter, all toolsets are enabled - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() - ids := reg.EnabledToolsetIDs() - if len(ids) != 2 { - t.Fatalf("Expected 2 enabled toolset IDs, got %d", len(ids)) - } - - // With filter - filtered := NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1"}).Build() - 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), @@ -563,7 +631,7 @@ func TestAllTools(t *testing.T) { } // Even with read-only filter, AllTools returns everything - readOnlyReg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true).Build() + readOnlyReg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true)) allTools := readOnlyReg.AllTools() if len(allTools) != 2 { @@ -628,7 +696,7 @@ func TestForMCPRequest_Initialize(t *testing.T) { mockPrompt("prompt1", "repos"), } - reg := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest(MCPMethodInitialize, "") // Initialize should return empty - capabilities come from ServerOptions @@ -655,7 +723,7 @@ func TestForMCPRequest_ToolsList(t *testing.T) { mockPrompt("prompt1", "repos"), } - reg := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest(MCPMethodToolsList, "") // tools/list should return all tools, no resources or prompts @@ -677,7 +745,7 @@ func TestForMCPRequest_ToolsCall(t *testing.T) { mockTool("list_repos", "repos", true), } - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest(MCPMethodToolsCall, "get_me") available := filtered.AvailableTools(context.Background()) @@ -694,7 +762,7 @@ func TestForMCPRequest_ToolsCall_NotFound(t *testing.T) { mockTool("get_me", "context", true), } - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest(MCPMethodToolsCall, "nonexistent") if len(filtered.AvailableTools(context.Background())) != 0 { @@ -708,11 +776,11 @@ func TestForMCPRequest_ToolsCall_DeprecatedAlias(t *testing.T) { mockTool("list_commits", "repos", true), } - reg := NewBuilder().SetTools(tools). + reg := mustBuild(t, NewBuilder().SetTools(tools). WithToolsets([]string{"all"}). WithDeprecatedAliases(map[string]string{ "old_get_me": "get_me", - }).Build() + })) // Request using the deprecated alias filtered := reg.ForMCPRequest(MCPMethodToolsCall, "old_get_me") @@ -732,7 +800,7 @@ func TestForMCPRequest_ToolsCall_RespectsFilters(t *testing.T) { } // Apply read-only filter at build time, then ForMCPRequest - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithReadOnly(true)) filtered := reg.ForMCPRequest(MCPMethodToolsCall, "create_issue") // The tool exists in the filtered group, but AvailableTools respects read-only @@ -754,7 +822,7 @@ func TestForMCPRequest_ResourcesList(t *testing.T) { mockPrompt("prompt1", "repos"), } - reg := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest(MCPMethodResourcesList, "") if len(filtered.AvailableTools(context.Background())) != 0 { @@ -774,18 +842,16 @@ func TestForMCPRequest_ResourcesRead(t *testing.T) { mockResource("res2", "repos", "branch://{owner}/{repo}/{branch}"), } - reg := NewBuilder().SetResources(resources).WithToolsets([]string{"all"}).Build() - filtered := reg.ForMCPRequest(MCPMethodResourcesRead, "repo://{owner}/{repo}") + reg := mustBuild(t, NewBuilder().SetResources(resources).WithToolsets([]string{"all"})) + // Pass a concrete URI - all resources remain registered, SDK handles matching + filtered := reg.ForMCPRequest(MCPMethodResourcesRead, "repo://owner/repo") + // All resources should be available - SDK handles URI template matching internally available := filtered.AvailableResourceTemplates(context.Background()) - if len(available) != 1 { - t.Fatalf("Expected 1 resource for resources/read, got %d", len(available)) - } - if available[0].Template.URITemplate != "repo://{owner}/{repo}" { - t.Errorf("Expected URI template 'repo://{owner}/{repo}', got %q", available[0].Template.URITemplate) + if len(available) != 2 { + t.Fatalf("Expected 2 resources for resources/read (SDK handles matching), got %d", len(available)) } } - func TestForMCPRequest_PromptsList(t *testing.T) { tools := []ServerTool{ mockTool("tool1", "repos", true), @@ -798,7 +864,7 @@ func TestForMCPRequest_PromptsList(t *testing.T) { mockPrompt("prompt2", "issues"), } - reg := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest(MCPMethodPromptsList, "") if len(filtered.AvailableTools(context.Background())) != 0 { @@ -818,7 +884,7 @@ func TestForMCPRequest_PromptsGet(t *testing.T) { mockPrompt("prompt2", "issues"), } - reg := NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest(MCPMethodPromptsGet, "prompt1") available := filtered.AvailablePrompts(context.Background()) @@ -841,7 +907,7 @@ func TestForMCPRequest_UnknownMethod(t *testing.T) { mockPrompt("prompt1", "repos"), } - reg := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest("unknown/method", "") // Unknown methods should return empty @@ -868,7 +934,7 @@ func TestForMCPRequest_DoesNotMutateOriginal(t *testing.T) { mockPrompt("prompt1", "repos"), } - original := NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"}).Build() + original := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).SetPrompts(prompts).WithToolsets([]string{"all"})) filtered := original.ForMCPRequest(MCPMethodToolsCall, "tool1") // Original should be unchanged @@ -903,10 +969,9 @@ func TestForMCPRequest_ChainedWithOtherFilters(t *testing.T) { } // Chain: default toolsets -> read-only -> specific method - reg := NewBuilder().SetTools(tools). + reg := mustBuild(t, NewBuilder().SetTools(tools). WithToolsets([]string{"default"}). - WithReadOnly(true). - Build() + WithReadOnly(true)) filtered := reg.ForMCPRequest(MCPMethodToolsList, "") available := filtered.AvailableTools(context.Background()) @@ -944,7 +1009,7 @@ func TestForMCPRequest_ResourcesTemplatesList(t *testing.T) { mockResource("res1", "repos", "repo://{owner}/{repo}"), } - reg := NewBuilder().SetTools(tools).SetResources(resources).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).SetResources(resources).WithToolsets([]string{"all"})) filtered := reg.ForMCPRequest(MCPMethodResourcesTemplatesList, "") // Same behavior as resources/list @@ -983,7 +1048,9 @@ func TestMCPMethodConstants(t *testing.T) { func mockToolWithFlags(name string, toolsetID string, readOnly bool, enableFlag, disableFlag string) ServerTool { tool := mockTool(name, toolsetID, readOnly) tool.FeatureFlagEnable = enableFlag - tool.FeatureFlagDisable = disableFlag + if disableFlag != "" { + tool.FeatureFlagDisable = []string{disableFlag} + } return tool } @@ -993,29 +1060,29 @@ func TestFeatureFlagEnable(t *testing.T) { mockToolWithFlags("needs_flag", "toolset1", true, "my_feature", ""), } - // Without feature checker, tool with FeatureFlagEnable should be excluded - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + // 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 := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerFalse).Build() + regFalse := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerFalse)) availableFalse := regFalse.AvailableTools(context.Background()) if len(availableFalse) != 1 { t.Fatalf("Expected 1 tool with false checker, got %d", len(availableFalse)) } + 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) { return flag == "my_feature", nil } - regTrue := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerTrue).Build() + regTrue := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerTrue)) availableTrue := regTrue.AvailableTools(context.Background()) if len(availableTrue) != 2 { t.Fatalf("Expected 2 tools with true checker, got %d", len(availableTrue)) @@ -1029,7 +1096,7 @@ func TestFeatureFlagDisable(t *testing.T) { } // Without feature checker, tool with FeatureFlagDisable should be included (flag is false) - reg := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) available := reg.AvailableTools(context.Background()) if len(available) != 2 { t.Fatalf("Expected 2 tools without feature checker, got %d", len(available)) @@ -1039,7 +1106,7 @@ func TestFeatureFlagDisable(t *testing.T) { checkerTrue := func(_ context.Context, flag string) (bool, error) { return flag == "kill_switch", nil } - regFiltered := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerTrue).Build() + regFiltered := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerTrue)) availableFiltered := regFiltered.AvailableTools(context.Background()) if len(availableFiltered) != 1 { t.Fatalf("Expected 1 tool with kill_switch enabled, got %d", len(availableFiltered)) @@ -1057,21 +1124,21 @@ func TestFeatureFlagBoth(t *testing.T) { // Enable flag not set -> excluded checker1 := func(_ context.Context, _ string) (bool, error) { return false, nil } - reg1 := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker1).Build() + reg1 := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker1)) if len(reg1.AvailableTools(context.Background())) != 0 { t.Error("Tool should be excluded when enable flag is false") } // Enable flag set, disable flag not set -> included checker2 := func(_ context.Context, flag string) (bool, error) { return flag == "new_feature", nil } - reg2 := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker2).Build() + reg2 := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker2)) if len(reg2.AvailableTools(context.Background())) != 1 { t.Error("Tool should be included when enable flag is true and disable flag is false") } // Enable flag set, disable flag also set -> excluded (disable wins) checker3 := func(_ context.Context, _ string) (bool, error) { return true, nil } - reg3 := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker3).Build() + reg3 := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checker3)) if len(reg3.AvailableTools(context.Background())) != 0 { t.Error("Tool should be excluded when both flags are true (disable wins)") } @@ -1086,7 +1153,7 @@ func TestFeatureFlagError(t *testing.T) { checkerError := func(_ context.Context, _ string) (bool, error) { return false, fmt.Errorf("simulated error") } - reg := NewBuilder().SetTools(tools).WithFeatureChecker(checkerError).Build() + reg := mustBuild(t, NewBuilder().SetTools(tools).WithFeatureChecker(checkerError)) available := reg.AvailableTools(context.Background()) if len(available) != 0 { t.Errorf("Expected 0 tools when checker errors, got %d", len(available)) @@ -1103,16 +1170,16 @@ func TestFeatureFlagResources(t *testing.T) { }, } - // Without checker, resource with enable flag should be excluded - reg := NewBuilder().SetResources(resources).WithToolsets([]string{"all"}).Build() + // 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 checker := func(_ context.Context, _ string) (bool, error) { return true, nil } - regWithChecker := NewBuilder().SetResources(resources).WithToolsets([]string{"all"}).WithFeatureChecker(checker).Build() + regWithChecker := mustBuild(t, NewBuilder().SetResources(resources).WithToolsets([]string{"all"}).WithFeatureChecker(checker)) if len(regWithChecker.AvailableResourceTemplates(context.Background())) != 2 { t.Errorf("Expected 2 resources with checker, got %d", len(regWithChecker.AvailableResourceTemplates(context.Background()))) } @@ -1128,16 +1195,16 @@ func TestFeatureFlagPrompts(t *testing.T) { }, } - // Without checker, prompt with enable flag should be excluded - reg := NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}).Build() + // 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 checker := func(_ context.Context, _ string) (bool, error) { return true, nil } - regWithChecker := NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}).WithFeatureChecker(checker).Build() + regWithChecker := mustBuild(t, NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"}).WithFeatureChecker(checker)) if len(regWithChecker.AvailablePrompts(context.Background())) != 2 { t.Errorf("Expected 2 prompts with checker, got %d", len(regWithChecker.AvailablePrompts(context.Background()))) } @@ -1220,7 +1287,7 @@ func TestServerToolEnabled(t *testing.T) { tool := mockTool("test_tool", "toolset1", true) tool.Enabled = tt.enabledFunc - reg := NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"all"})) available := reg.AvailableTools(context.Background()) if len(available) != tt.expectedCount { @@ -1252,7 +1319,7 @@ func TestServerToolEnabledWithContext(t *testing.T) { return user != nil && user.(string) == "authorized", nil } - reg := NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"all"}).Build() + reg := mustBuild(t, NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"all"})) // Without user in context - tool should be excluded available := reg.AvailableTools(context.Background()) @@ -1288,11 +1355,10 @@ func TestBuilderWithFilter(t *testing.T) { return tool.Tool.Name != "tool2", nil } - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets([]string{"all"}). - WithFilter(filter). - Build() + WithFilter(filter)) available := reg.AvailableTools(context.Background()) if len(available) != 2 { @@ -1324,12 +1390,11 @@ func TestBuilderWithMultipleFilters(t *testing.T) { return tool.Tool.Name != "tool3", nil } - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets([]string{"all"}). WithFilter(filter1). - WithFilter(filter2). - Build() + WithFilter(filter2)) available := reg.AvailableTools(context.Background()) if len(available) != 2 { @@ -1359,11 +1424,10 @@ func TestBuilderFilterError(t *testing.T) { return false, fmt.Errorf("filter error") } - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets([]string{"all"}). - WithFilter(filter). - Build() + WithFilter(filter)) available := reg.AvailableTools(context.Background()) if len(available) != 0 { @@ -1389,11 +1453,10 @@ func TestBuilderFilterWithContext(t *testing.T) { return true, nil } - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets([]string{"all"}). - WithFilter(filter). - Build() + WithFilter(filter)) // With public scope - private_tool should be excluded ctxPublic := context.WithValue(context.Background(), scopeKey, "public") @@ -1422,10 +1485,11 @@ func TestEnabledAndFeatureFlagInteraction(t *testing.T) { } // Feature flag not enabled - tool should be excluded despite Enabled returning true - reg1 := NewBuilder(). + checkerOff := func(_ context.Context, _ string) (bool, error) { return false, nil } + reg1 := mustBuild(t, NewBuilder(). SetTools([]ServerTool{tool}). WithToolsets([]string{"all"}). - Build() + WithFeatureChecker(checkerOff)) available1 := reg1.AvailableTools(context.Background()) if len(available1) != 0 { t.Error("Tool should be excluded when feature flag is not enabled") @@ -1435,11 +1499,10 @@ func TestEnabledAndFeatureFlagInteraction(t *testing.T) { checker := func(_ context.Context, flag string) (bool, error) { return flag == "my_feature", nil } - reg2 := NewBuilder(). + reg2 := mustBuild(t, NewBuilder(). SetTools([]ServerTool{tool}). WithToolsets([]string{"all"}). - WithFeatureChecker(checker). - Build() + WithFeatureChecker(checker)) available2 := reg2.AvailableTools(context.Background()) if len(available2) != 1 { t.Error("Tool should be included when both Enabled and feature flag pass") @@ -1449,11 +1512,10 @@ func TestEnabledAndFeatureFlagInteraction(t *testing.T) { tool.Enabled = func(_ context.Context) (bool, error) { return false, nil } - reg3 := NewBuilder(). + reg3 := mustBuild(t, NewBuilder(). SetTools([]ServerTool{tool}). WithToolsets([]string{"all"}). - WithFeatureChecker(checker). - Build() + WithFeatureChecker(checker)) available3 := reg3.AvailableTools(context.Background()) if len(available3) != 0 { t.Error("Tool should be excluded when Enabled returns false") @@ -1471,11 +1533,10 @@ func TestEnabledAndBuilderFilterInteraction(t *testing.T) { return false, nil } - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools([]ServerTool{tool}). WithToolsets([]string{"all"}). - WithFilter(filter). - Build() + WithFilter(filter)) available := reg.AvailableTools(context.Background()) if len(available) != 0 { @@ -1499,12 +1560,11 @@ func TestAllFiltersInteraction(t *testing.T) { } // All conditions pass - tool should be included - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools([]ServerTool{tool}). WithToolsets([]string{"all"}). WithFeatureChecker(checker). - WithFilter(filter). - Build() + WithFilter(filter)) available := reg.AvailableTools(context.Background()) if len(available) != 1 { @@ -1516,12 +1576,11 @@ func TestAllFiltersInteraction(t *testing.T) { return false, nil } - reg2 := NewBuilder(). + reg2 := mustBuild(t, NewBuilder(). SetTools([]ServerTool{tool}). WithToolsets([]string{"all"}). WithFeatureChecker(checker). - WithFilter(filterFalse). - Build() + WithFilter(filterFalse)) available2 := reg2.AvailableTools(context.Background()) if len(available2) != 0 { @@ -1540,11 +1599,10 @@ func TestFilteredTools(t *testing.T) { return tool.Tool.Name == "tool1", nil } - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets([]string{"all"}). - WithFilter(filter). - Build() + WithFilter(filter)) filtered, err := reg.FilteredTools(context.Background()) if err != nil { @@ -1567,11 +1625,10 @@ func TestFilteredToolsMatchesAvailableTools(t *testing.T) { mockTool("tool3", "toolset2", true), } - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets([]string{"toolset1"}). - WithReadOnly(true). - Build() + WithReadOnly(true)) ctx := context.Background() filtered, err := reg.FilteredTools(ctx) @@ -1598,10 +1655,10 @@ func TestFilteredToolsMatchesAvailableTools(t *testing.T) { func TestFilteringOrder(t *testing.T) { // Test that filters are applied in the correct order: // 1. Tool.Enabled - // 2. Feature flags - // 3. Read-only - // 4. Builder filters - // 5. Toolset/additional tools + // 2. Read-only + // 3. Builder filters (feature-flag filter is at the head of this list + // when WithFeatureChecker is set) + // 4. Toolset/additional tools callOrder := []string{} @@ -1621,18 +1678,22 @@ func TestFilteringOrder(t *testing.T) { return true, nil } - reg := NewBuilder(). + reg := mustBuild(t, NewBuilder(). SetTools([]ServerTool{tool}). WithToolsets([]string{"all"}). WithReadOnly(true). // This will exclude the tool (it's not read-only) WithFeatureChecker(checker). - WithFilter(filter). - Build() + WithFilter(filter)) + + // 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) } @@ -1655,17 +1716,18 @@ func TestForMCPRequest_ToolsCall_FeatureFlaggedVariants(t *testing.T) { } // Test 1: Flag is OFF - first tool variant should be available - regFlagOff := NewBuilder(). + checkerOff := func(_ context.Context, _ string) (bool, error) { return false, nil } + regFlagOff := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets([]string{"all"}). - Build() + 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) } @@ -1673,18 +1735,542 @@ func TestForMCPRequest_ToolsCall_FeatureFlaggedVariants(t *testing.T) { checker := func(_ context.Context, flag string) (bool, error) { return flag == "consolidated_flag", nil } - regFlagOn := NewBuilder(). + regFlagOn := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets([]string{"all"}). - WithFeatureChecker(checker). - Build() + WithFeatureChecker(checker)) filteredOn := regFlagOn.ForMCPRequest(MCPMethodToolsCall, "get_job_logs") availableOn := filteredOn.AvailableTools(context.Background()) if len(availableOn) != 1 { t.Fatalf("Flag ON: Expected 1 tool, got %d", len(availableOn)) } if availableOn[0].FeatureFlagEnable != "consolidated_flag" { - t.Errorf("Flag ON: Expected tool with FeatureFlagEnable, got FeatureFlagEnable=%q, FeatureFlagDisable=%q", + t.Errorf("Flag ON: Expected tool with FeatureFlagEnable, got FeatureFlagEnable=%q, FeatureFlagDisable=%v", availableOn[0].FeatureFlagEnable, availableOn[0].FeatureFlagDisable) } } + +// TestWithTools_DeprecatedAliasAndFeatureFlag tests that deprecated aliases work correctly +// when the old tool is controlled by a feature flag. This covers the scenario where: +// - Old tool "old_tool" has FeatureFlagDisable="my_flag" (available when flag is OFF) +// - New tool "new_tool" has FeatureFlagEnable="my_flag" (available when flag is ON) +// - Deprecated alias maps "old_tool" -> "new_tool" +// - User specifies --tools=old_tool +// Expected behavior: +// - Flag OFF: old_tool should be available (not the new_tool via alias) +// - Flag ON: new_tool should be available (via alias resolution) +func TestWithTools_DeprecatedAliasAndFeatureFlag(t *testing.T) { + oldTool := mockToolWithFlags("old_tool", "actions", true, "", "my_flag") + newTool := mockToolWithFlags("new_tool", "actions", true, "my_flag", "") + tools := []ServerTool{oldTool, newTool} + + deprecatedAliases := map[string]string{ + "old_tool": "new_tool", + } + + // Test 1: Flag OFF - old_tool should be available via direct name match + // (not via alias resolution to new_tool, since old_tool still exists) + 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 + WithFeatureChecker(checkerOff)) + availableOff := regFlagOff.AvailableTools(context.Background()) + if len(availableOff) != 1 { + t.Fatalf("Flag OFF: Expected 1 tool, got %d", len(availableOff)) + } + if availableOff[0].Tool.Name != "old_tool" { + t.Errorf("Flag OFF: Expected old_tool, got %s", availableOff[0].Tool.Name) + } + + // Test 2: Flag ON - new_tool should be available via alias resolution + checker := func(_ context.Context, flag string) (bool, error) { + return flag == "my_flag", nil + } + regFlagOn := mustBuild(t, NewBuilder(). + SetTools(tools). + WithDeprecatedAliases(deprecatedAliases). + WithToolsets([]string{}). // No toolsets enabled + WithTools([]string{"old_tool"}). // Request old tool name + WithFeatureChecker(checker)) + availableOn := regFlagOn.AvailableTools(context.Background()) + if len(availableOn) != 1 { + t.Fatalf("Flag ON: Expected 1 tool, got %d", len(availableOn)) + } + if availableOn[0].Tool.Name != "new_tool" { + t.Errorf("Flag ON: Expected new_tool (via alias), got %s", availableOn[0].Tool.Name) + } +} + +// 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) +} + +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). +func captureRegisteredTools(ctx context.Context, t *testing.T, reg *Inventory) []*mcp.Tool { + t.Helper() + tools := reg.AvailableTools(ctx) + out := make([]*mcp.Tool, 0, len(tools)) + for i := range tools { + toolCopy := tools[i].Tool + out = append(out, &toolCopy) + } + if shouldStripMCPAppsMetadata(ctx, reg.checkFeatureFlag(ctx, mcpAppsFeatureFlag)) { + for _, tt := range out { + delete(tt.Meta, "ui") + if len(tt.Meta) == 0 { + tt.Meta = nil + } + } + } + return out +} + +// TestShouldStripMCPAppsMetadata verifies the spec-conformant strip decision: +// strip when the feature flag is off, OR when the client explicitly does not +// advertise the io.modelcontextprotocol/ui extension. +func TestShouldStripMCPAppsMetadata(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupCtx func() context.Context + ffOn bool + want bool + }{ + { + name: "FF off, capability unknown -> strip", + setupCtx: context.Background, + ffOn: false, + want: true, + }, + { + name: "FF off, capability present -> strip (FF wins)", + setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), true) }, + ffOn: false, + want: true, + }, + { + name: "FF on, capability unknown -> keep", + setupCtx: context.Background, + ffOn: true, + want: false, + }, + { + name: "FF on, capability present -> keep", + setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), true) }, + ffOn: true, + want: false, + }, + { + name: "FF on, capability explicitly absent -> strip", + setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), false) }, + ffOn: true, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := shouldStripMCPAppsMetadata(tc.setupCtx(), tc.ffOn) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/pkg/inventory/resources.go b/pkg/inventory/resources.go index 6de037d584..2dd07ae0fe 100644 --- a/pkg/inventory/resources.go +++ b/pkg/inventory/resources.go @@ -19,9 +19,9 @@ type ServerResourceTemplate struct { // FeatureFlagEnable specifies a feature flag that must be enabled for this resource // to be available. If set and the flag is not enabled, the resource is omitted. FeatureFlagEnable string - // FeatureFlagDisable specifies a feature flag that, when enabled, causes this resource - // to be omitted. Used to disable resources when a feature flag is on. - FeatureFlagDisable string + // FeatureFlagDisable specifies feature flags that, when any is enabled, cause this + // resource to be omitted. Used to disable resources when a feature flag is on. + FeatureFlagDisable []string } // HasHandler returns true if this resource has a handler function. diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 095bedf2bf..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" @@ -31,6 +32,9 @@ type ToolsetMetadata struct { // Use the base name without size suffix, e.g., "repo" not "repo-16". // See https://primer.style/foundations/icons for available icons. Icon string + // InstructionsFunc optionally returns instructions for this toolset. + // It receives the inventory so it can check what other toolsets are enabled. + InstructionsFunc func(inv *Inventory) string } // Icons returns MCP Icon objects for this toolset, or nil if no icon is set. @@ -60,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 @@ -115,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. @@ -154,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 @@ -163,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/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 new file mode 100644 index 0000000000..e46b028504 --- /dev/null +++ b/pkg/tooldiscovery/search.go @@ -0,0 +1,311 @@ +package tooldiscovery + +import ( + "sort" + "strings" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/lithammer/fuzzysearch/fuzzy" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type SearchResult struct { + Tool mcp.Tool `json:"tool"` + Score float64 `json:"score"` + MatchedIn []string `json:"matchedIn"` // Signals that contributed to scoring (e.g. name:token, description, parameter:token). +} + +const ( + DefaultMaxSearchResults = 3 + + // Scoring weights used by scoreTool. + substringMatchScore = 5 + exactTokensMatchScore = 2.5 + descriptionMatchScore = 2 + prefixMatchScore = 1.5 + parameterMatchScore = 1 +) + +// SearchOptions configures search behavior. +type SearchOptions struct { + MaxResults int `json:"maxResults"` // Maximum number of results to return (default: 3) +} + +// Search returns the most relevant tools for a free-text query. +// +// Prefer using SearchTools and passing an explicit tool list. This function is +// kept for API compatibility and currently searches an empty tool set. +func Search(query string, options ...SearchOptions) ([]SearchResult, error) { + return SearchTools(nil, query, options...) +} + +// SearchTools is like Search, but searches across the provided tool list. +// +// Matching uses a weighted combination of: +// - tool name matches (strongest) +// - description matches +// - input parameter name matches (JSON schema property names) +// - fuzzy similarity as a tie-breaker +// +// Empty or whitespace-only queries return (nil, nil). +func SearchTools(tools []mcp.Tool, query string, options ...SearchOptions) ([]SearchResult, error) { + maxResults := getMaxResults(options) + + query = strings.TrimSpace(query) + if query == "" { + return nil, nil + } + + queryLower := strings.ToLower(query) + queryTokens := strings.Fields(queryLower) + normalizedQueryCompact := strings.ReplaceAll(strings.ReplaceAll(queryLower, " ", ""), "_", "") + + results := make([]SearchResult, 0, len(tools)) + for _, tool := range tools { + score, matchedIn := scoreTool(tool, queryLower, queryTokens, normalizedQueryCompact) + results = append(results, SearchResult{ + Tool: tool, + Score: score, + MatchedIn: matchedIn, + }) + } + + sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score }) + + // Filter out low-relevance results + const minScore = 1.0 + filtered := results[:0] + for _, r := range results { + if r.Score > minScore { + filtered = append(filtered, r) + } + } + results = filtered + + // Limit results + if len(results) > maxResults { + results = results[:maxResults] + } + + return results, nil +} + +// scoreTool assigns a relevance score to a tool for the given query. +// +// It combines several signals (substrings, token coverage, and similarity) from: +// - tool name +// - tool description +// - input parameter names (schema property names) +// +// MatchedIn records which signals contributed to the score for debugging/tuning. +func scoreTool( + tool mcp.Tool, + queryLower string, + queryTokens []string, + normalizedQueryCompact string, +) (score float64, matchedIn []string) { + nameLower := strings.ToLower(tool.Name) + descLower := strings.ToLower(tool.Description) + + normalizedNameCompact := strings.ReplaceAll(nameLower, "_", "") + nameTokens := splitTokens(nameLower) + propertyNames := lowerInputPropertyNames(tool.InputSchema) + + matches := newMatchTracker(3) + score = 0.0 + + // Strong boosts for direct substring matches + if strings.Contains(nameLower, queryLower) { + score += substringMatchScore + matches.Add("name:substring") + } + if strings.HasPrefix(nameLower, queryLower) { + score += prefixMatchScore + matches.Add("name:prefix") + } + if normalizedNameCompact == normalizedQueryCompact && len(queryTokens) > 1 { + score += exactTokensMatchScore + matches.Add("name:exact-tokens") + } + if strings.Contains(descLower, queryLower) { + score += descriptionMatchScore + matches.Add("description") + } + + for _, prop := range propertyNames { + if strings.Contains(prop, queryLower) { + score += parameterMatchScore + matches.Add("parameter") + } + } + + matchedTokens := make(map[string]struct{}) + + // Token-level matches for multi-word queries + for _, token := range queryTokens { + if strings.Contains(nameLower, token) { + score++ + matchedTokens[token] = struct{}{} + matches.Add("name:token") + } else if strings.Contains(descLower, token) { + score += 0.6 + matchedTokens[token] = struct{}{} + matches.Add("description:token") + } + + for _, prop := range propertyNames { + if strings.Contains(prop, token) { + // Only credit the first parameter match per token to avoid double-counting + score += 0.4 + matchedTokens[token] = struct{}{} + matches.Add("parameter:token") + break + } + } + } + + tokenCoverage := float64(len(matchedTokens)) + score += tokenCoverage * 0.8 + if len(queryTokens) > 1 && len(matchedTokens) == len(queryTokens) { + score += 2 // bonus when all tokens are matched somewhere + } + + // Prefer names that cover query tokens directly, with fewer extra tokens + nameTokenMatches := 0 + for _, qt := range queryTokens { + for _, nt := range nameTokens { + if strings.Contains(nt, qt) { + nameTokenMatches++ + break + } + } + } + if nameTokenMatches == len(queryTokens) { + score += 4.0 // all tokens present in name tokens + if len(nameTokens) == len(queryTokens) { + score += 2.0 // exact token count match (e.g., issue_write vs sub_issue_write) + } + } + extraTokens := len(nameTokens) - nameTokenMatches + if extraTokens > 0 { + score -= float64(extraTokens) * 0.5 // stronger penalty for extra unrelated tokens + } + + // Similarity scores to soften ordering among close matches + nameSim := normalizedSimilarity(nameLower, queryLower) + descSim := normalizedSimilarity(descLower, queryLower) + + var propSim float64 + for _, prop := range propertyNames { + if sim := normalizedSimilarity(prop, queryLower); sim > propSim { + propSim = sim + } + } + + searchText := nameLower + " " + descLower + if len(propertyNames) > 0 { + searchText += " " + strings.Join(propertyNames, " ") + } + fuzzySim := normalizedSimilarity(searchText, queryLower) + + score += nameSim * 2 + score += descSim * 0.8 + score += propSim * 0.6 + score += fuzzySim * 0.5 + + return score, matches.List() +} + +func getMaxResults(options []SearchOptions) int { + maxResults := DefaultMaxSearchResults + if len(options) > 0 && options[0].MaxResults > 0 { + maxResults = options[0].MaxResults + } + return maxResults +} + +func lowerInputPropertyNames(inputSchema any) []string { + if inputSchema == nil { + return nil + } + + // From the server, this is commonly a *jsonschema.Schema. + if schema, ok := inputSchema.(*jsonschema.Schema); ok { + if len(schema.Properties) == 0 { + return nil + } + out := make([]string, 0, len(schema.Properties)) + for prop := range schema.Properties { + out = append(out, strings.ToLower(prop)) + } + return out + } + + // From the client (or when unmarshaled), schemas arrive as map[string]any. + if schema, ok := inputSchema.(map[string]any); ok { + propsAny, ok := schema["properties"] + if !ok { + return nil + } + props, ok := propsAny.(map[string]any) + if !ok || len(props) == 0 { + return nil + } + out := make([]string, 0, len(props)) + for prop := range props { + out = append(out, strings.ToLower(prop)) + } + return out + } + + return nil +} + +type matchTracker struct { + list []string + seen map[string]struct{} +} + +func newMatchTracker(capacity int) *matchTracker { + return &matchTracker{ + list: make([]string, 0, capacity), + seen: make(map[string]struct{}, capacity), + } +} + +func (m *matchTracker) Add(part string) { + if _, ok := m.seen[part]; ok { + return + } + m.seen[part] = struct{}{} + m.list = append(m.list, part) +} + +func (m *matchTracker) List() []string { + return m.list +} + +func normalizedSimilarity(a, b string) float64 { + if len(a) == 0 || len(b) == 0 { + return 0 + } + + distance := fuzzy.LevenshteinDistance(a, b) + maxLen := max(len(b), len(a)) + + similarity := 1 - (float64(distance) / float64(maxLen)) + if similarity < 0 { + return 0 + } + + return similarity +} + +func splitTokens(s string) []string { + if s == "" { + return nil + } + return strings.FieldsFunc(s, func(r rune) bool { + return r == '_' || r == '-' || r == ' ' + }) +} diff --git a/pkg/tooldiscovery/search_test.go b/pkg/tooldiscovery/search_test.go new file mode 100644 index 0000000000..79d6fe8dda --- /dev/null +++ b/pkg/tooldiscovery/search_test.go @@ -0,0 +1,57 @@ +package tooldiscovery + +import ( + "testing" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" +) + +func TestSearchTools_EmptyQueryReturnsNil(t *testing.T) { + results, err := SearchTools([]mcp.Tool{{Name: "issue_list"}}, " ") + require.NoError(t, err) + require.Nil(t, results) +} + +func TestSearchTools_FindsByName(t *testing.T) { + tools := []mcp.Tool{ + {Name: "issue_list", Description: "List issues"}, + {Name: "repo_get", Description: "Get repository"}, + } + + results, err := SearchTools(tools, "issue", SearchOptions{MaxResults: 10}) + require.NoError(t, err) + require.NotEmpty(t, results) + require.Equal(t, "issue_list", results[0].Tool.Name) +} + +func TestSearchTools_FindsByParameterName_JSONSchema(t *testing.T) { + tools := []mcp.Tool{ + { + Name: "unrelated_tool", + Description: "does something else", + InputSchema: &jsonschema.Schema{Properties: map[string]*jsonschema.Schema{"owner": {}}}, + }, + } + + results, err := SearchTools(tools, "owner", SearchOptions{MaxResults: 10}) + require.NoError(t, err) + require.NotEmpty(t, results) + require.Equal(t, "unrelated_tool", results[0].Tool.Name) +} + +func TestSearchTools_FindsByParameterName_MapSchema(t *testing.T) { + tools := []mcp.Tool{ + { + Name: "unrelated_tool", + Description: "does something else", + InputSchema: map[string]any{"properties": map[string]any{"repo": map[string]any{}}}, + }, + } + + results, err := SearchTools(tools, "repo", SearchOptions{MaxResults: 10}) + require.NoError(t, err) + require.NotEmpty(t, results) + require.Equal(t, "unrelated_tool", results[0].Tool.Name) +} diff --git a/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..1bfd800e28 100644 --- a/pkg/utils/result.go +++ b/pkg/utils/result.go @@ -47,3 +47,15 @@ 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, + } +} 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/licenses b/script/licenses index 5aa8ec16b6..23686315b1 100755 --- a/script/licenses +++ b/script/licenses @@ -18,13 +18,9 @@ # depending on the license. set -e -# Pinned version for CI reproducibility, latest for local development +# Pinned version for reproducibility # See: https://github.com/cli/cli/pull/11161 -if [ "$CI" = "true" ]; then - go install github.com/google/go-licenses@5348b744d0983d85713295ea08a20cca1654a45e # v2.0.1 -else - go install github.com/google/go-licenses@latest -fi +go install github.com/google/go-licenses/v2@v2.0.1 # actions/setup-go does not setup the installed toolchain to be preferred over the system install, # which causes go-licenses to raise "Package ... does not have module info" errors in CI. diff --git a/script/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/server.json b/server.json index 83b4e06bec..15fdf47bdb 100644 --- a/server.json +++ b/server.json @@ -8,6 +8,31 @@ "source": "github" }, "version": "${VERSION}", + "packages": [ + { + "registryType": "oci", + "identifier": "ghcr.io/github/github-mcp-server:${VERSION}", + "transport": { + "type": "stdio" + }, + "runtimeArguments": [ + { + "type": "named", + "name": "-e", + "description": "Set an environment variable in the runtime", + "value": "GITHUB_PERSONAL_ACCESS_TOKEN={token}", + "isRequired": true, + "variables": { + "token": { + "isRequired": true, + "isSecret": true, + "format": "string" + } + } + } + ] + } + ], "remotes": [ { "type": "streamable-http", @@ -15,8 +40,7 @@ "headers": [ { "name": "Authorization", - "description": "Authentication token (PAT or App token)", - "isRequired": true, + "description": "Authorization header with authentication token (PAT or App token)", "isSecret": true } ] diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index fb4392fb94..5f56c1c89b 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -15,21 +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-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.4.0/LICENSE)) - - [github.com/google/go-github/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/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/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/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE)) + - [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/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)) @@ -40,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 564f20dcb7..7d8213d2f2 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -15,21 +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-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.4.0/LICENSE)) - - [github.com/google/go-github/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/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/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/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE)) + - [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/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)) @@ -40,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 6b4dcfb975..3d0fd8f386 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -15,22 +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-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.4.0/LICENSE)) - - [github.com/google/go-github/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/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/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/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE)) + - [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/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 +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/yudai/golcs/LICENSE b/third-party/github.com/lithammer/fuzzysearch/fuzzy/LICENSE similarity index 88% rename from third-party/github.com/yudai/golcs/LICENSE rename to third-party/github.com/lithammer/fuzzysearch/fuzzy/LICENSE index ab7d2e0fba..dee3d1de25 100644 --- a/third-party/github.com/yudai/golcs/LICENSE +++ b/third-party/github.com/lithammer/fuzzysearch/fuzzy/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2015 Iwasaki Yudai +Copyright (c) 2018 Peter Lithammer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -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/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/segmentio/asm/LICENSE b/third-party/github.com/segmentio/asm/LICENSE new file mode 100644 index 0000000000..29e1ab6b05 --- /dev/null +++ b/third-party/github.com/segmentio/asm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +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 +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/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..13d78a25a8 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,6330 @@ +{ + "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.13", + "vite-plugin-singlefile": "^2.3.3" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", + "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "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.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "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.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "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.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "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.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@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.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "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.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@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.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "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/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@styled-system/background": { + "version": "5.1.2", + "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.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "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.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "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.30001767", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", + "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", + "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.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "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.19", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz", + "integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==", + "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.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT", + "peer": true + }, + "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.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "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.11", + "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.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "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.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.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.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^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..b5bf095851 --- /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.13", + "vite-plugin-singlefile": "^2.3.3" + } +} diff --git a/ui/scripts/build.mjs b/ui/scripts/build.mjs new file mode 100644 index 0000000000..c99d846039 --- /dev/null +++ b/ui/scripts/build.mjs @@ -0,0 +1,14 @@ +// Build all UI apps in a single Node process. +// +// Replaces three serial `cross-env APP= vite build` invocations: doing it +// in one process avoids paying Vite/plugin startup cost three times and is +// portable without `cross-env`. + +import { build } from "vite"; + +const apps = ["get-me", "issue-write", "pr-write"]; + +for (const app of apps) { + process.env.APP = app; + await build(); +} diff --git a/ui/src/apps/get-me/App.tsx b/ui/src/apps/get-me/App.tsx 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..863543fc14 --- /dev/null +++ b/ui/src/apps/issue-write/App.tsx @@ -0,0 +1,333 @@ +import { StrictMode, useState, useCallback, useEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { + Box, + Text, + TextInput, + Button, + Flash, + Spinner, + FormControl, +} from "@primer/react"; +import { + IssueOpenedIcon, + CheckCircleIcon, +} 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; +} + +function SuccessView({ + issue, + owner, + repo, + submittedTitle, + isUpdate, +}: { + issue: IssueResult; + owner: string; + repo: string; + submittedTitle: string; + isUpdate: boolean; +}) { + const issueUrl = issue.html_url || issue.url || issue.URL || "#"; + + return ( + + + + + + + {isUpdate ? "Issue updated successfully" : "Issue created successfully"} + + + + + + + + + + {issue.title || submittedTitle} + {issue.number && ( + + #{issue.number} + + )} + + + {owner}/{repo} + + + + + ); +} + +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); + + const { app, error: appError, toolInput, callTool, hostContext, setModelContext } = useMcpApp({ + appName: "github-mcp-server-issue-write", + }); + + const method = (toolInput?.method as string) || "create"; + const issueNumber = toolInput?.issue_number as number | undefined; + const isUpdateMode = method === "update" && issueNumber !== undefined; + const owner = (toolInput?.owner as string) || ""; + const repo = (toolInput?.repo as string) || ""; + + // Pre-fill from toolInput + useEffect(() => { + if (toolInput?.title) setTitle(toolInput.title as string); + if (toolInput?.body) setBody(toolInput.body as string); + }, [toolInput]); + + const handleSubmit = useCallback(async () => { + if (!title.trim()) { + setError("Title is required"); + return; + } + if (!owner || !repo) { + setError("Repository information not available"); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + const params: Record = { + method: isUpdateMode ? "update" : "create", + owner, + repo, + title: title.trim(), + body: body.trim(), + _ui_submitted: true + }; + + if (isUpdateMode && issueNumber) { + params.issue_number = issueNumber; + } + + 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, isUpdateMode, issueNumber, callTool, setModelContext]); + + const body_node = (() => { + if (appError) { + return ( + + Connection error: {appError.message} + + ); + } + + if (!app) { + return ( + + + + ); + } + + if (successIssue) { + return ( + + ); + } + + return ( + + {/* Header */} + + + + + + {isUpdateMode ? `Update issue #${issueNumber}` : "New issue"} + + + {owner}/{repo} + + + + {/* Error banner */} + {error && ( + + {error} + + )} + + {/* Title */} + + + Title + + setTitle(e.target.value)} + placeholder="Title" + block + contrast + /> + + + {/* Description */} + + + Description + + + + + {/* Submit button */} + + + + + ); + })(); + + 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-write/App.tsx b/ui/src/apps/pr-write/App.tsx new file mode 100644 index 0000000000..bfefdbede0 --- /dev/null +++ b/ui/src/apps/pr-write/App.tsx @@ -0,0 +1,348 @@ +import { StrictMode, useState, useCallback, useEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { + Box, + Text, + TextInput, + Button, + Flash, + Spinner, + FormControl, + ActionMenu, + ActionList, + Checkbox, + ButtonGroup, +} from "@primer/react"; +import { + GitPullRequestIcon, + CheckCircleIcon, + TriangleDownIcon, +} 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; +} + +function SuccessView({ + pr, + owner, + repo, + submittedTitle, +}: { + pr: PRResult; + owner: string; + repo: string; + submittedTitle: string; +}) { + const prUrl = pr.html_url || pr.url || pr.URL || "#"; + + return ( + + + + + + + Pull request created successfully + + + + + + + + + + {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); + + const [isDraft, setIsDraft] = useState(false); + const [maintainerCanModify, setMaintainerCanModify] = useState(true); + + const { app, error: appError, toolInput, callTool, hostContext, setModelContext } = useMcpApp({ + appName: "github-mcp-server-create-pull-request", + }); + + const owner = (toolInput?.owner as string) || ""; + const repo = (toolInput?.repo as string) || ""; + const head = (toolInput?.head as string) || ""; + const base = (toolInput?.base as string) || ""; + const [submittedTitle, setSubmittedTitle] = useState(""); + + // Pre-fill from toolInput + useEffect(() => { + if (toolInput?.title) setTitle(toolInput.title as string); + if (toolInput?.body) setBody(toolInput.body as string); + if (toolInput?.draft) setIsDraft(toolInput.draft as boolean); + if (toolInput?.maintainer_can_modify !== undefined) { + setMaintainerCanModify(toolInput.maintainer_can_modify as boolean); + } + }, [toolInput]); + + const handleSubmit = useCallback(async () => { + if (!title.trim()) { setError("Title is required"); return; } + if (!owner || !repo) { setError("Repository information not available"); return; } + + setIsSubmitting(true); + setError(null); + setSubmittedTitle(title); + + try { + const result = await callTool("create_pull_request", { + owner, repo, + title: title.trim(), + body: body.trim(), + head, + base, + draft: isDraft, + maintainer_can_modify: maintainerCanModify, + _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, head, base, isDraft, maintainerCanModify, callTool, setModelContext]); + + if (successPR) { + return ( + + + + ); + } + + if (!app && !appError) { + return ( + + + + + + ); + } + + if (appError) { + return ( + + {appError.message} + + ); + } + + return ( + + + {/* Header */} + + + + + New pull request + + {owner}/{repo} + + {head && base && ( + + {base} ← {head} + + )} + + + {/* Error banner */} + {error && {error}} + + {/* Title */} + + Title + setTitle(e.target.value)} + placeholder="Title" + block + contrast + /> + + + {/* Description */} + + + Description + + + + + {/* 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