diff --git a/.github/actions/build-ui/action.yml b/.github/actions/build-ui/action.yml index 1ab6dc4e73..d45360ae14 100644 --- a/.github/actions/build-ui/action.yml +++ b/.github/actions/build-ui/action.yml @@ -1,26 +1,26 @@ name: Build UI -description: Restore cached UI HTML artifacts, or set up Node and run script/build-ui on cache miss. +description: Restore tested 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 + uses: actions/cache@v6 with: path: | pkg/github/ui_dist/get-me.html pkg/github/ui_dist/issue-write.html pkg/github/ui_dist/pr-write.html pkg/github/ui_dist/pr-edit.html - key: ui-dist-v2-${{ hashFiles('ui/package-lock.json', 'ui/package.json', 'ui/index.html', 'ui/tsconfig*.json', 'ui/vite.config.ts', 'ui/src/**', 'ui/scripts/**') }} + key: ui-dist-v3-${{ hashFiles('.github/actions/build-ui/action.yml', 'script/build-ui', '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 + uses: actions/setup-node@v7 with: - node-version: "22" + node-version-file: ui/package.json cache: npm cache-dependency-path: ui/package-lock.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d43dcee742..c04aa0c593 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,6 +9,10 @@ updates: directory: "/" schedule: interval: "weekly" + - package-ecosystem: "npm" + directory: "/ui" + schedule: + interval: "weekly" - package-ecosystem: "docker" directory: "/" schedule: @@ -17,3 +21,5 @@ updates: directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 diff --git a/.github/workflows/ai-issue-assessment.yml b/.github/workflows/ai-issue-assessment.yml index 1d4e0c35c7..20962cc3be 100644 --- a/.github/workflows/ai-issue-assessment.yml +++ b/.github/workflows/ai-issue-assessment.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Run AI assessment uses: github/ai-assessment-comment-labeler@e3bedc38cfffa9179fe4cee8f7ecc93bffb3fee7 # v1.0.1 diff --git a/.github/workflows/close-inactive-issues.yml b/.github/workflows/close-inactive-issues.yml index 84d91d1e48..57124ebbe2 100644 --- a/.github/workflows/close-inactive-issues.yml +++ b/.github/workflows/close-inactive-issues.yml @@ -14,7 +14,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@v10 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11 with: days-before-issue-stale: ${{ env.PR_DAYS_BEFORE_STALE }} days-before-issue-close: ${{ env.PR_DAYS_BEFORE_CLOSE }} diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml index 48ffe98664..26d61a2faa 100644 --- a/.github/workflows/code-scanning.yml +++ b/.github/workflows/code-scanning.yml @@ -41,10 +41,10 @@ jobs: runner: '["ubuntu-22.04"]' steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.4 + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -61,18 +61,18 @@ jobs: threat-models: [ ] - name: Setup proxy for registries id: proxy - uses: github/codeql-action/start-proxy@v4.37.4 + uses: github/codeql-action/start-proxy@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: registries_credentials: ${{ secrets.GITHUB_REGISTRIES_PROXY }} language: ${{ matrix.language }} - name: Configure - uses: github/codeql-action/resolve-environment@v4.37.4 + uses: github/codeql-action/resolve-environment@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 id: resolve-environment with: language: ${{ matrix.language }} - name: Setup Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 if: matrix.language == 'go' && fromJSON(steps.resolve-environment.outputs.environment).configuration.go.version with: go-version: ${{ fromJSON(steps.resolve-environment.outputs.environment).configuration.go.version }} @@ -80,9 +80,9 @@ jobs: - name: Set up Node.js (for JavaScript CodeQL) if: matrix.language == 'javascript' - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: - node-version: "20" + node-version-file: ui/package.json cache: "npm" cache-dependency-path: ui/package-lock.json @@ -91,10 +91,10 @@ jobs: uses: ./.github/actions/build-ui - name: Autobuild - uses: github/codeql-action/autobuild@v4.37.4 + uses: github/codeql-action/autobuild@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.4 + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 env: CODEQL_PROXY_HOST: ${{ steps.proxy.outputs.proxy_host }} CODEQL_PROXY_PORT: ${{ steps.proxy.outputs.proxy_port }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b06914cfb9..d443bc36f8 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # Install the cosign tool except on PR # https://github.com/sigstore/cosign-installer @@ -48,13 +48,13 @@ jobs: if: github.event_name != 'pull_request' uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 #v4.1.2 with: - cosign-release: "v2.2.4" + cosign-release: "v3.1.3" # Set up BuildKit Docker container builder to be able to build # multi-platform images and export cache # https://github.com/docker/setup-buildx-action - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 # Login against a Docker registry except on PR # https://github.com/docker/login-action @@ -87,7 +87,7 @@ jobs: type=raw,value=latest,enable=${{ github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }} - name: Go Build Cache for Docker - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: go-build-cache key: ${{ runner.os }}-go-build-cache-${{ hashFiles('**/go.sum') }} @@ -106,7 +106,7 @@ jobs: # https://github.com/docker/build-push-action - name: Build and push Docker image id: build-and-push - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . push: ${{ github.event_name != 'pull_request' }} @@ -116,23 +116,26 @@ jobs: cache-to: type=gha,mode=max platforms: linux/amd64,linux/arm64 build-args: | - VERSION=${{ github.ref_name }} + VERSION=${{ github.ref_type == 'tag' && github.ref_name || 'dev' }} secrets: | oauth_client_id=${{ secrets.OAUTH_CLIENT_ID }} oauth_client_secret=${{ secrets.OAUTH_CLIENT_SECRET }} # Sign the resulting Docker image digest except on PRs. - # This will only write to the public Rekor transparency log when the Docker - # repository is public to avoid leaking data. If you would like to publish - # transparency data even for private images, pass --force to cosign below. + # Publish both v2-compatible .sig tags and native Sigstore bundles. + # --yes also consents to publishing signing metadata to the public log. # https://github.com/sigstore/cosign - - name: Sign the published Docker image + - name: Sign the published Docker image (legacy) if: ${{ github.event_name != 'pull_request' }} env: # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable - TAGS: ${{ steps.meta.outputs.tags }} DIGEST: ${{ steps.build-and-push.outputs.digest }} # This step uses the identity token to provision an ephemeral certificate # against the sigstore community Fulcio instance. - run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} - \ No newline at end of file + run: cosign sign --yes --new-bundle-format=false --use-signing-config=false --registry-referrers-mode=legacy "${REGISTRY}/${IMAGE_NAME}@${DIGEST}" + + - name: Sign the published Docker image (native bundle) + if: ${{ github.event_name != 'pull_request' }} + env: + DIGEST: ${{ steps.build-and-push.outputs.digest }} + run: cosign sign --yes "${REGISTRY}/${IMAGE_NAME}@${DIGEST}" diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 0514a1afe4..b7b681a591 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -14,13 +14,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Build UI uses: ./.github/actions/build-ui - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: 'go.mod' diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 165c8e3815..1475aca261 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -23,13 +23,13 @@ jobs: git config --global core.eol lf - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Build UI uses: ./.github/actions/build-ui - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: "go.mod" diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 12e680a049..c0de29ba51 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -14,13 +14,13 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Build UI uses: ./.github/actions/build-ui - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: "go.mod" @@ -42,7 +42,7 @@ jobs: OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }} - name: Generate signed build provenance attestations for workflow artifacts - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4 with: subject-path: | dist/*.tar.gz diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index 80016c4a37..2accf3bcf7 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # Check out the actual PR branch so we can push changes back if needed - name: Check out PR branch @@ -36,7 +36,7 @@ jobs: uses: ./.github/actions/build-ui - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: "go.mod" @@ -70,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@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { data: comments } = await github.rest.issues.listComments({ @@ -88,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@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | await github.rest.issues.createComment({ diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6119ee9f0f..94f771cbb0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,14 +13,14 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Build UI uses: ./.github/actions/build-ui - - uses: actions/setup-go@v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: - go-version: '1.25' + go-version-file: 'go.mod' - name: golangci-lint - uses: golangci/golangci-lint-action@v9 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 with: # sync with script/lint - version: v2.9 + version: v2.14.0 diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml index 653d71093a..0e1ec42f10 100644 --- a/.github/workflows/mcp-diff.yml +++ b/.github/workflows/mcp-diff.yml @@ -15,12 +15,12 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: go.mod @@ -85,12 +85,12 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: go.mod @@ -119,7 +119,14 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Run MCP Server Diff (streamable-http) + id: diff # Pinned to mcp-server-diff v3.0.0 — see rationale on the stdio job above. + # The action sleeps http_startup_wait_ms and then probes, with no + # readiness check. Build the binary in install_command (which runs to + # completion on both the current and baseline checkouts before the + # server is started) so the wait only covers process startup, not + # compilation. Using `go run` here let compile time on the uncached + # baseline checkout exceed the wait, so every baseline probe failed. uses: SamMorrowDrums/mcp-server-diff@40d992e0a220e5b63378758f9a40d6a8982898d2 # v3.0.0 with: setup_go: "false" @@ -127,10 +134,26 @@ jobs: 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 + go build -o bin/github-mcp-server ./cmd/github-mcp-server + http_start_command: ./bin/github-mcp-server http --port 8082 http_startup_wait_ms: "5000" configurations: ${{ steps.configs.outputs.configurations }} + - name: Verify both sides were probed + # mcp-server-diff treats a configuration that fails on only one side as + # "missing on that ref" (non-fatal) and diffs against an empty + # baseline, so fail_on_error does not catch an unreachable server. + # With a single shared HTTP server, a one-sided failure always means + # that side's server never came up, so fail the job explicitly. + env: + REPORT: ${{ steps.diff.outputs.json_report_path }} + run: | + jq -e 'all(.results[]; .configMissing == null)' "$REPORT" > /dev/null || { + jq -r '.results[] | select(.configMissing) | "::error::\(.configName): server unreachable on \(.configMissing.side) side: \(.configMissing.error)"' "$REPORT" + exit 1 + } + jq -r '.results[] | "\(.configName): base=\(.baseProtocolVersion) head=\(.branchProtocolVersion) tools base=\(.baseCounts.tools) head=\(.branchCounts.tools)"' "$REPORT" + - name: Add interpretation note if: always() run: | diff --git a/.github/workflows/moderator.yml b/.github/workflows/moderator.yml index e0726d0146..b96c65f174 100644 --- a/.github/workflows/moderator.yml +++ b/.github/workflows/moderator.yml @@ -16,8 +16,8 @@ jobs: models: read contents: read steps: - - uses: actions/checkout@v7 - - uses: github/ai-moderator@v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: github/ai-moderator@81159c370785e295c97461ade67d7c33576e9319 # v1 with: token: ${{ secrets.GITHUB_TOKEN }} spam-label: 'spam' diff --git a/.github/workflows/registry-releaser.yml b/.github/workflows/registry-releaser.yml index 7ab683f721..a1db9759cf 100644 --- a/.github/workflows/registry-releaser.yml +++ b/.github/workflows/registry-releaser.yml @@ -14,10 +14,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version: "stable" diff --git a/.golangci.yml b/.golangci.yml index a32fc897e8..bde46b8e80 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -31,10 +31,17 @@ linters: - examples$ - internal/githubv4mock rules: + # v2.9.0 misreports SA4006 for Go 1.26 new(expr); retain go-github's helper until the linter is upgraded. + - linters: + - staticcheck + text: '(go)?github\.Ptr is deprecated: use the new builtin instead\.' - linters: - revive text: "var-naming: avoid package names that conflict with Go standard library package names" settings: + modernize: + disable: + - newexpr staticcheck: checks: - "all" diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 36dfc47bce..fa70312feb 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -9,7 +9,7 @@ builds: - env: - CGO_ENABLED=0 ldflags: - - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID={{ .Env.OAUTH_CLIENT_ID }} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret={{ .Env.OAUTH_CLIENT_SECRET }} + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X github.com/github/github-mcp-server/v2/internal/buildinfo.OAuthClientID={{ .Env.OAUTH_CLIENT_ID }} -X github.com/github/github-mcp-server/v2/internal/buildinfo.OAuthClientSecret={{ .Env.OAUTH_CLIENT_SECRET }} goos: - linux - windows diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c16cd27dd..0c97c238c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,8 +26,27 @@ Thanks for contributing and for helping us build toolsets that are truly valuabl These are one time installations required to be able to test your changes locally as part of the pull request (PR) submission process. -1. Install Go [through download](https://go.dev/doc/install) | [through Homebrew](https://formulae.brew.sh/formula/go) -2. [Install golangci-lint v2](https://golangci-lint.run/welcome/install/#local-installation) +1. Install Go 1.26.8 or later [through download](https://go.dev/doc/install) | [through Homebrew](https://formulae.brew.sh/formula/go) +2. [Install golangci-lint v2.14.0](https://golangci-lint.run/welcome/install/#local-installation), or let `script/lint` install the repository-pinned version. The pinned version supports both Go 1.26 and Go 1.27. + +### UI development + +Use Node.js 26.x for the UI in `ui/`. This matches the Docker UI build stage +and `@types/node`; GitHub Actions reads the Node version from `ui/package.json`. +Node 26 is a supported Current release, with LTS scheduled for October 2026. + +From `ui/`, run `npm ci`, `npm run typecheck`, and `npm run build`. +Type checking uses the native TypeScript 7 compiler through `tsc`; Vite handles +transpilation and bundling separately. No TypeScript compiler API integration, +typescript-eslint, or ts-node is required. + +### MCP Apps UI + +The `ui/` views use React 19 and Primer React 38. With Node.js 20.19+ or 22.12+, run `cd ui && npm ci && npm run typecheck && npm run build && npm audit` before `script/test`. The build writes self-contained HTML to `pkg/github/ui_dist/`, which the Go server embeds; these generated files are not committed. + +Primer 38 no longer exports `Box` or accepts `sx`/styled-system props. Use semantic HTML, native `style` props for dynamic/layout styles, and CSS Modules for nested selectors. `AppProvider` loads Primer's primitive tokens and light/dark themes, so use CSS variables rather than JavaScript theme values. Custom element typings must augment `react/jsx-runtime` rather than the global `JSX` namespace. + +For UI dependency upgrades, compare all four views (`get-me`, `issue-write`, `pr-write`, and `pr-edit`) in light/dark themes and at narrow widths in an MCP Apps host, including menus, Markdown editing/preview, and completed-result views. Include before/after screenshots in the pull request. ## Submitting a pull request @@ -41,6 +60,7 @@ These are one time installations required to be able to test your changes locall - Update readme documentation: `script/generate-docs` - If renaming a tool, add a deprecation alias (see [Tool Renaming Guide](docs/tool-renaming.md)) - For toolset and icon configuration, see [Toolsets and Icons Guide](docs/toolsets-and-icons.md) + - For typed tool registration and compatibility schemas, see [Typed Tool Schemas](docs/typed-tool-schemas.md) 6. Push to your fork and [submit a pull request][pr] targeting the `main` branch 7. Pat yourself on the back and wait for your pull request to be reviewed and merged. diff --git a/Dockerfile b/Dockerfile index 8619f089ef..e555c50045 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ COPY ui/ ./ui/ RUN mkdir -p ./pkg/github/ui_dist && \ cd ui && npm run build -FROM golang:1.27.0-alpine@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS build +FROM golang:1.27.1-alpine@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414 AS build ARG VERSION="dev" # Set the working directory @@ -31,7 +31,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=secret,id=oauth_client_secret \ export OAUTH_CLIENT_ID="$(cat /run/secrets/oauth_client_id 2>/dev/null || echo '')" && \ export OAUTH_CLIENT_SECRET="$(cat /run/secrets/oauth_client_secret 2>/dev/null || echo '')" && \ - 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) -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=${OAUTH_CLIENT_ID} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret=${OAUTH_CLIENT_SECRET}" \ + 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) -X github.com/github/github-mcp-server/v2/internal/buildinfo.OAuthClientID=${OAUTH_CLIENT_ID} -X github.com/github/github-mcp-server/v2/internal/buildinfo.OAuthClientSecret=${OAUTH_CLIENT_SECRET}" \ -o /bin/github-mcp-server ./cmd/github-mcp-server # Make a stage to run the app diff --git a/README.md b/README.md index d8d8695d2a..6413b58760 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Go Report Card](https://goreportcard.com/badge/github.com/github/github-mcp-server)](https://goreportcard.com/report/github.com/github/github-mcp-server) +[![Go Report Card](https://goreportcard.com/badge/github.com/github/github-mcp-server/v2)](https://goreportcard.com/report/github.com/github/github-mcp-server/v2) # GitHub MCP Server @@ -14,6 +14,18 @@ The GitHub MCP Server connects AI tools directly to GitHub's platform. This give 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. +### Using the Go module + +The Go module path is `github.com/github/github-mcp-server/v2`. Library consumers +must use this prefix in imports, for example +`github.com/github/github-mcp-server/v2/pkg/github`, and in `go get` commands. +No `/v2` subdirectory is needed when building from a repository checkout. + +The existing `v2.0.0` tag predates this module-path correction and cannot be used +as a Go module. After this change is merged, a new release such as `v2.0.1` must +be tagged from a commit containing the corrected module path. Until then, +consumers can use a pseudo-version from a commit containing this change. + --- ## Remote GitHub MCP Server @@ -403,7 +415,13 @@ For a complete overview of all installation options, see our **[Installation Gui ### Build from source If you don't have Docker, you can use `go build` to build the binary in the -`cmd/github-mcp-server` directory, and use the `github-mcp-server stdio` command with the `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable set to your token. To specify the output location of the build, use the `-o` flag. You should configure your server to use the built executable as its `command`. For example: +`cmd/github-mcp-server` directory, and use the `github-mcp-server stdio` command with the `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable set to your token. To specify the output location of the build, use the `-o` flag. You should configure your server to use the built executable as its `command`. + +STDIO API requests identify the server as `github-mcp-server/` and retain the upstream MCP client's name/version in parentheses when available. Control characters, quotes, backslashes and parentheses in client metadata are escaped so the HTTP header remains valid; ordinary names, versions, spaces and printable Unicode are preserved. Release builds keep their release version. Source and default Docker builds with revision metadata use `vcs-`. The Docker publishing workflow preserves tag versions, including prereleases, and lets branch, pull-request and nightly builds use the linked source revision. The `-dirty` marker applies only when both the revision and modified state come from embedded VCS metadata, never to a valid, explicitly supplied `main.commit`. This is a VCS build identifier, not a release number. + +Build the complete package with `go build -o github-mcp-server ./cmd/github-mcp-server` from a Git checkout to embed its VCS revision. For builds without VCS metadata, supply the actual release with `-ldflags '-X main.version='` or the full source revision with `-ldflags '-X main.commit='`. Valid metadata is selected in this order: explicit release, explicit source revision, embedded VCS revision, then installed main-module version. Valid explicit revisions remain authoritative even if build-context filtering changes embedded VCS metadata. Missing or malformed candidates fall through to the next usable source. If none is available, STDIO still starts with the development label `dev` and emits a warning on stderr, leaving stdout available for the MCP protocol. Supply real release or revision metadata when version-specific attribution is needed. + +For example: ```JSON { @@ -421,17 +439,6 @@ 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. @@ -597,6 +604,7 @@ The following sets of tools are available: | comment-discussion | `discussions` | GitHub Discussions related tools | | logo-gist | `gists` | GitHub Gist related tools | | git-branch | `git` | GitHub Git API related tools for low-level Git operations | +| law | `governance` | Repository governance tools for managing rulesets and custom properties at the repository, organization, and enterprise levels | | issue-opened | `issues` | GitHub Issues related tools | | tag | `labels` | GitHub Labels related tools | | bell | `notifications` | GitHub Notifications related tools | @@ -620,6 +628,18 @@ The following sets of tools are available: ## Tools +Repository tools expose output schemas and typed `structuredContent` when the negotiated MCP protocol version is a supported version `2026-07-28` or newer. Older clients, clients with an unknown protocol version, and clients omitting the version receive the existing content without an output schema or structured output. Unknown versions are not treated as supported merely because they sort after `2026-07-28`. + +For `get_file_contents`, directory results (including field projections) are structured arrays. File downloads and metadata-only responses use a `content` array of typed text, embedded-resource, or resource-link blocks, preserving status messages, text, base64 blobs, empty files, and download links. Text-only repository mutations such as starring and deletion also return a structured `message`; fork responses distinguish a repository reference from an in-progress message. Repository deletion still requires its existing confirmation flow before returning a completed result. + +Structured repository results use compact DTOs rather than raw REST objects. Release lookups share the compact release shape with release listings; Git references retain the ref, object type, and SHA; annotated tags retain tag/message/tagger/target information; deletion commits retain SHA, message, authors, tree SHA, parent SHAs, and a human-facing `html_url` when available. Directory entries retain file metadata and useful HTML/download links, but omit API and hypermedia routing URLs even when those fields were requested in a projection. Legacy and unknown protocol versions retain the original text byte-for-byte; modern text-only JSON responses serialize the same compact DTO as `structuredContent`. Non-text resource blocks and tool errors retain their existing content behavior. Structured projections use the same sanitized and filtered source as the legacy formatter. + +For `get_repository_tree`, `list_gists`, `get_gist`, `create_gist`, and `update_gist`, gist results retain file names, contents, sizes, language/type information, timestamps, visibility, and compact owner profiles, while omitting API/node and Git transport URLs. Tree entries omit their derivable API URLs. Create/update gist results contain the gist ID and HTML URL. Modern JSON text and structured content serialize the same compact result. Older or unknown protocol versions retain byte-for-byte legacy text without an output schema or structured content. + +The issue metadata (`list_issue_types`, `list_issue_fields`), comment (`add_issue_comment`, `update_issue_comment`), dependency (`issue_dependency_read`, `issue_dependency_write`), and duplicate-detection (`find_duplicate`) tools provide typed `outputSchema` and `structuredContent` for negotiated supported protocol versions `2026-07-28` or newer. Older or unknown protocol versions retain the same text responses without these fields. Tool errors do not return structured content. Dependency and duplicate-detection tools retain their existing feature gates. + +For negotiated supported protocol versions `2026-07-28` or newer, the consolidated Actions tools (`actions_list`, `actions_get`, `actions_run_trigger`) return structured objects with a `method` discriminant and a method-specific typed field; JSON text contains the same compact projection. Workflow, job, and artifact projections omit API/hypermedia URLs while retaining browser links and useful identifiers, states, timestamps, and usage durations. Run usage lists billable runner environments in deterministic name order. Older and unknown-protocol text responses remain unchanged; `get_job_logs` retains its typed content, URL, or failed-job collection variants. +
@@ -642,7 +662,7 @@ The following sets of tools are available: - `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) + - `perPage`: Results per page for pagination (default: 30, max: 100) (number, optional) - `repo`: Repository name (string, required) - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: - Do not provide any resource ID for 'list_workflows' method. @@ -714,6 +734,7 @@ The following sets of tools are available: person Context - **get_me** - Get my user profile + - **MCP App UI**: `ui://github-mcp-server/get-me` - No parameters required - **get_team_members** - Get team members @@ -725,6 +746,12 @@ The following sets of tools are available: - **OAuth Challenge Scopes**: `read:org` - `user`: Username to get teams for. If not provided, uses the authenticated user. (string, optional) +- **ui_get** - Get UI data + - **OAuth Challenge Scopes**: `repo`, `read:org` + - `method`: The type of data to fetch (string, required) + - `owner`: Repository owner (required for all methods) (string, required) + - `repo`: Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers) (string, optional) +
@@ -883,6 +910,81 @@ The following sets of tools are available:
+law Governance + +- **create_repository_ruleset** - Create repository ruleset + - **OAuth Challenge Scopes**: `repo`, `admin:org`, `admin:enterprise` + - `bypass_actors`: The actors that can bypass the rules in this ruleset (object[], optional) + - `conditions`: Conditions for when this ruleset applies, e.g. {"ref_name": {"include": ["refs/heads/main"], "exclude": []}} (object, optional) + - `enforcement`: The enforcement level of the ruleset. 'evaluate' allows admins to test rules before enforcing them (string, required) + - `enterprise`: Enterprise slug. Required when level is 'enterprise'. (string, optional) + - `level`: The level at which the ruleset is configured: + - 'repository': A ruleset on a single repository (requires 'owner' and 'repo'). + - 'organization': A ruleset covering repositories in an organization (requires 'org'). + - 'enterprise': A ruleset covering repositories across an enterprise (requires 'enterprise'). (string, required) + - `name`: The name of the ruleset (string, required) + - `org`: Organization name. Required when level is 'organization'. (string, optional) + - `owner`: Repository owner. Required when level is 'repository'. (string, optional) + - `repo`: Repository name. Required when level is 'repository'. (string, optional) + - `rules`: An array of rules within the ruleset. Each rule is an object with a 'type' (e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks') and, for rules that need configuration, a 'parameters' object (object[], required) + - `target`: The target of the ruleset. Defaults to 'branch'. 'repository' is only valid for 'organization' and 'enterprise' level rulesets. (string, optional) + +- **custom_properties_read** - Read custom properties + - **OAuth Challenge Scopes**: `repo`, `read:org`, `read:enterprise` + - `enterprise`: Enterprise slug. Required when level is 'enterprise'. (string, optional) + - `level`: The level at which custom properties are managed: + - 'repository': The custom property VALUES assigned to a repository (requires 'owner' and 'repo'). + - 'organization': The custom property DEFINITIONS (schema) for an organization (requires 'org'). + - 'enterprise': The custom property DEFINITIONS (schema) for an enterprise (requires 'enterprise'). (string, required) + - `org`: Organization name. Required when level is 'organization'. (string, optional) + - `owner`: Repository owner. Required when level is 'repository'. (string, optional) + - `repo`: Repository name. Required when level is 'repository'. (string, optional) + +- **custom_properties_write** - Set custom properties + - **OAuth Challenge Scopes**: `repo`, `admin:org`, `admin:enterprise` + - `enterprise`: Enterprise slug. Required when level is 'enterprise'. (string, optional) + - `level`: The level at which custom properties are managed: + - 'repository': The custom property VALUES assigned to a repository (requires 'owner' and 'repo'). + - 'organization': The custom property DEFINITIONS (schema) for an organization (requires 'org'). + - 'enterprise': The custom property DEFINITIONS (schema) for an enterprise (requires 'enterprise'). (string, required) + - `org`: Organization name. Required when level is 'organization'. (string, optional) + - `owner`: Repository owner. Required when level is 'repository'. (string, optional) + - `properties`: The custom properties to create or update. At the repository level each item assigns a value ('property_name' and 'value'); at the organization and enterprise levels each item defines the schema ('property_name' and 'value_type', plus optional definition fields). (object[], required) + - `repo`: Repository name. Required when level is 'repository'. (string, optional) + +- **repository_ruleset_read** - Read repository rulesets + - **OAuth Challenge Scopes**: `repo`, `read:org`, `read:enterprise` + - `actor_name`: The handle for the GitHub user account to filter rule suites on. Used by the 'list_rule_suites' method. (string, optional) + - `branch`: Branch name. Required for the 'get_rules_for_branch' method. (string, optional) + - `enterprise`: Enterprise slug. Required when level is 'enterprise'. (string, optional) + - `evaluate_status`: Filter rule suites by ruleset evaluation mode. Used by the 'list_rule_suites' method. (string, optional) + - `includes_parents`: Include rulesets configured at higher levels that also apply. Defaults to true. Used by the 'get' and 'list' methods at the repository level. (boolean, optional) + - `level`: The level at which the ruleset is configured: + - 'repository': A ruleset on a single repository (requires 'owner' and 'repo'). + - 'organization': A ruleset covering repositories in an organization (requires 'org'). + - 'enterprise': A ruleset covering repositories across an enterprise (requires 'enterprise'). (string, required) + - `method`: Operation to perform: + - 'get': Get a specific ruleset by ID (requires 'ruleset_id'). Supported at every level. + - 'list': List all rulesets. Supported at every level. + - 'get_rules_for_branch': Get all rules that apply to a branch (requires 'branch'). Repository level only. + - 'list_rule_suites': List rule suites, the evaluations of rules against pushes. Repository and organization levels only. + - 'get_rule_suite': Get a specific rule suite by ID (requires 'rule_suite_id'). Repository and organization levels only. (string, required) + - `org`: Organization name. Required when level is 'organization'. (string, optional) + - `owner`: Repository owner. Required when level is 'repository'. (string, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `ref`: The name of the ref (branch, tag, etc.) to filter rule suites by. Used by the 'list_rule_suites' method. (string, optional) + - `repo`: Repository name. Required when level is 'repository'. (string, optional) + - `repository_name`: Repository name to filter rule suites by. Used by the 'list_rule_suites' method at the organization level. (string, optional) + - `rule_suite_id`: Rule suite ID. Required for the 'get_rule_suite' method. (number, optional) + - `rule_suite_result`: The rule suite result to filter by. Used by the 'list_rule_suites' method. (string, optional) + - `ruleset_id`: Ruleset ID. Required for the 'get' method. (number, optional) + - `time_period`: The time period to filter rule suites by. Used by the 'list_rule_suites' method. (string, optional) + +
+ +
+ issue-opened Issues - **add_issue_comment** - Add comment to issue or pull request @@ -918,6 +1020,7 @@ The following sets of tools are available: - **issue_write** - Create or update issue/pull request - **OAuth Challenge 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. Required when state_reason is 'duplicate'. (number, optional) @@ -992,6 +1095,13 @@ The following sets of tools are available: - `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) +- **update_issue_comment** - Update issue comment + - **OAuth Challenge Scopes**: `repo` + - `body`: New comment content (string, required) + - `comment_id`: The numeric ID of the issue or pull request conversation comment to update. Do not use a pull request review comment ID. (integer, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) +
@@ -1103,7 +1213,7 @@ The following sets of tools are available: - `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) + - `perPage`: Results per page (max 50) (number, optional) - `project_number`: The project's number. Required for 'list_project_fields', 'list_project_items', 'list_project_views', 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) @@ -1166,6 +1276,7 @@ The following sets of tools are available: - **create_pull_request** - Open new pull request - **OAuth Challenge 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) @@ -1193,6 +1304,7 @@ The following sets of tools are available: - **OAuth Challenge Scopes**: `repo` - `commit_message`: Extra detail for merge commit (string, optional) - `commit_title`: Title for merge commit (string, optional) + - `expectedHeadSha`: The expected SHA of the pull request's HEAD ref (string, optional) - `merge_method`: Merge method (string, optional) - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) @@ -1208,7 +1320,7 @@ The following sets of tools are available: 3. get_status - Get combined commit status of a head commit in a pull request. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned. - 6. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. + 6. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns thread metadata and comments with nullable current and original line-range coordinates (line, start_line, original_line, original_start_line). Current coordinates are omitted when unavailable, such as for outdated comments. Use cursor-based pagination (perPage, after) to control results. 7. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned. 8. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. 9. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. @@ -1243,6 +1355,7 @@ The following sets of tools are available: - **update_pull_request** - Edit pull request - **OAuth Challenge Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/pr-edit` - `base`: New base branch name (string, optional) - `body`: New description (string, optional) - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) @@ -1283,7 +1396,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. Required if the file already exists. (string, optional) + - `sha`: The blob SHA of the file being replaced. Required if the file already exists. Retrieve it with get_file_contents using the same owner, repo, and path, with ref set to this tool's branch value. (string, optional) - **create_repository** - Create repository - **OAuth Challenge Scopes**: `repo` @@ -1417,7 +1530,7 @@ The following sets of tools are available: - **search_repositories** - Search repositories - **OAuth Challenge Scopes**: `repo` - - `minimal_output`: Return minimal repository information (default: true). When false, returns full GitHub API repository objects. (boolean, optional) + - `minimal_output`: Return minimal repository information (default: true). When false, modern clients receive additional curated repository details, not the complete GitHub API object. Legacy clients retain full GitHub API repository objects. (boolean, optional) - `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) diff --git a/agent-plugin/mcp.json b/agent-plugin/mcp.json new file mode 100644 index 0000000000..86e6bd64a1 --- /dev/null +++ b/agent-plugin/mcp.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "github": { + "type": "streamable-http", + "url": "https://api.githubcopilot.com/mcp/" + } + } +} diff --git a/agent-plugin/plugin.json b/agent-plugin/plugin.json new file mode 100644 index 0000000000..a7f1f6919c --- /dev/null +++ b/agent-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "github", + "description": "Work with GitHub repositories, issues, pull requests, reviews, and code search through MCP.", + "author": { + "name": "GitHub", + "url": "https://github.com/" + }, + "homepage": "https://github.com/github/github-mcp-server", + "repository": "https://github.com/github/github-mcp-server", + "license": "MIT", + "keywords": ["code-search", "github", "issues", "mcp", "pull-requests"] +} diff --git a/cmd/github-mcp-server/feature_flag_docs.go b/cmd/github-mcp-server/feature_flag_docs.go index e52237b138..33e84908ac 100644 --- a/cmd/github-mcp-server/feature_flag_docs.go +++ b/cmd/github-mcp-server/feature_flag_docs.go @@ -8,9 +8,9 @@ import ( "sort" "strings" - "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" ) // generateInsidersFeaturesDocs refreshes the auto-generated section of diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 791a40b886..3fb793aaa1 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -9,9 +9,9 @@ import ( "sort" "strings" - "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/jsonschema-go/jsonschema" "github.com/spf13/cobra" ) @@ -61,9 +61,8 @@ func generateReadmeDocs(readmePath string) error { // 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. + // every flag as disabled keeps the default variants selected by functional + // feature rules, 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"}). @@ -223,9 +222,7 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { fmt.Fprintf(buf, " - **OAuth Challenge Scopes**: `%s`\n", strings.Join(scopes, "`, `")) } - // 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). + // MCP App UI metadata. 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) diff --git a/cmd/github-mcp-server/list_scopes.go b/cmd/github-mcp-server/list_scopes.go index 8d6d038d1e..25e6e87b5c 100644 --- a/cmd/github-mcp-server/list_scopes.go +++ b/cmd/github-mcp-server/list_scopes.go @@ -8,9 +8,9 @@ import ( "sort" "strings" - "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/spf13/cobra" "github.com/spf13/viper" ) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index c0cadbbc63..04ba68e226 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -5,17 +5,18 @@ import ( "errors" "fmt" "os" + "runtime/debug" "strings" "time" - "github.com/github/github-mcp-server/internal/buildinfo" - "github.com/github/github-mcp-server/internal/ghmcp" - "github.com/github/github-mcp-server/internal/githubapp" - "github.com/github/github-mcp-server/internal/oauth" - "github.com/github/github-mcp-server/pkg/github" - ghhttp "github.com/github/github-mcp-server/pkg/http" - ghoauth "github.com/github/github-mcp-server/pkg/http/oauth" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/internal/buildinfo" + "github.com/github/github-mcp-server/v2/internal/ghmcp" + "github.com/github/github-mcp-server/v2/internal/githubapp" + "github.com/github/github-mcp-server/v2/internal/oauth" + "github.com/github/github-mcp-server/v2/pkg/github" + ghhttp "github.com/github/github-mcp-server/v2/pkg/http" + ghoauth "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -38,7 +39,13 @@ var ( Use: "stdio", Short: "Start stdio server", Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`, - RunE: func(_ *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { + info, _ := debug.ReadBuildInfo() + serverVersion := resolveServerVersion(version, commit, info) + if serverVersion == developmentServerVersion { + cmd.PrintErrln("Warning: no usable server build version metadata; using development version dev") + } + token := viper.GetString("personal_access_token") appID := viper.GetString("app-id") appInstallationID := viper.GetString("app-installation-id") @@ -110,7 +117,7 @@ var ( ttl := viper.GetDuration("repo-access-cache-ttl") stdioServerConfig := ghmcp.StdioServerConfig{ - Version: version, + Version: serverVersion, Host: viper.GetString("host"), Token: token, EnabledToolsets: enabledToolsets, diff --git a/cmd/github-mcp-server/main_test.go b/cmd/github-mcp-server/main_test.go index a5b2b84967..48d1367acf 100644 --- a/cmd/github-mcp-server/main_test.go +++ b/cmd/github-mcp-server/main_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/viper" diff --git a/cmd/github-mcp-server/version.go b/cmd/github-mcp-server/version.go new file mode 100644 index 0000000000..51b9e5880c --- /dev/null +++ b/cmd/github-mcp-server/version.go @@ -0,0 +1,75 @@ +package main + +import ( + "encoding/hex" + "runtime/debug" + "strings" + "unicode" +) + +const developmentServerVersion = "dev" + +func resolveServerVersion(release, revision string, info *debug.BuildInfo) string { + isPlaceholder := func(value string) bool { + switch value { + case "", "version", "dev", "unknown", "(devel)": + return true + default: + return false + } + } + validRelease := func(value string) bool { + if isPlaceholder(value) { + return false + } + for _, r := range value { + if r > unicode.MaxASCII || r <= ' ' || r == 127 || strings.ContainsRune("()<>@,;:\\\"/[]?={}", r) { + return false + } + } + return true + } + if validRelease(release) { + return release + } + + var vcsRevision string + var dirty bool + if info != nil { + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + vcsRevision = setting.Value + case "vcs.modified": + dirty = setting.Value == "true" + } + } + } + revisions := []struct { + value string + dirty bool + }{ + {value: revision}, + {value: vcsRevision, dirty: dirty}, + } + for _, candidate := range revisions { + if len(candidate.value) != 40 && len(candidate.value) != 64 { + continue + } + if _, err := hex.DecodeString(candidate.value); err != nil { + continue + } + if strings.Trim(candidate.value, "0") == "" { + continue + } + resolved := "vcs-" + strings.ToLower(candidate.value) + if candidate.dirty { + resolved += "-dirty" + } + return resolved + } + if info != nil && validRelease(info.Main.Version) { + return info.Main.Version + } + return developmentServerVersion +} diff --git a/cmd/github-mcp-server/version_test.go b/cmd/github-mcp-server/version_test.go new file mode 100644 index 0000000000..4258b817c1 --- /dev/null +++ b/cmd/github-mcp-server/version_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "runtime/debug" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolveServerVersion(t *testing.T) { + t.Parallel() + sha := strings.Repeat("a1", 20) + otherSHA := strings.Repeat("b2", 20) + source := &debug.BuildInfo{ + Main: debug.Module{Version: "(devel)"}, + Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: sha}, {Key: "vcs.modified", Value: "false"}, + }, + } + dirty := &debug.BuildInfo{ + Main: debug.Module{Version: "(devel)"}, + Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: sha}, {Key: "vcs.modified", Value: "true"}, + }, + } + tests := []struct { + name string + release string + revision string + info *debug.BuildInfo + want string + }{ + {name: "release unchanged", release: "v1.2.3", revision: sha, info: dirty, want: "v1.2.3"}, + {name: "release suffix unchanged", release: "v1.2.3-rc.1+build.4", want: "v1.2.3-rc.1+build.4"}, + {name: "release before invalid revision", release: "v1.2.3", revision: "short", info: dirty, want: "v1.2.3"}, + {name: "source build", release: "version", revision: "commit", info: source, want: "vcs-" + sha}, + { + name: "source revision before inferred module version", release: "version", revision: "commit", + info: &debug.BuildInfo{ + Main: debug.Module{Version: "v1.2.4-0.20260916095829-a1a1a1a1a1a1+dirty"}, + Settings: dirty.Settings, + }, + want: "vcs-" + sha + "-dirty", + }, + {name: "dirty source", release: "version", revision: "commit", info: dirty, want: "vcs-" + sha + "-dirty"}, + {name: "Docker revision", release: "dev", revision: sha, want: "vcs-" + sha}, + {name: "Docker revision ignores context dirty state", release: "dev", revision: sha, info: dirty, want: "vcs-" + sha}, + {name: "linked revision ignores unrelated dirty state", release: "dev", revision: otherSHA, info: dirty, want: "vcs-" + otherSHA}, + {name: "linked revision precedence", release: "dev", revision: otherSHA, info: source, want: "vcs-" + otherSHA}, + {name: "SHA256", release: "dev", revision: strings.Repeat("ab", 32), want: "vcs-" + strings.Repeat("ab", 32)}, + {name: "canonical hex", release: "dev", revision: strings.ToUpper(sha), want: "vcs-" + sha}, + {name: "installed module", info: &debug.BuildInfo{Main: debug.Module{Version: "v1.2.3"}}, want: "v1.2.3"}, + {name: "absent build info", release: "version", revision: "commit", want: "dev"}, + {name: "missing VCS metadata", release: "dev", info: &debug.BuildInfo{}, want: "dev"}, + {name: "unknown placeholder", release: "unknown", want: "dev"}, + {name: "short revision", release: "dev", revision: "abcdef", want: "dev"}, + {name: "short revision falls through to VCS", release: "dev", revision: "abcdef", info: source, want: "vcs-" + sha}, + {name: "malformed linked revision falls through to VCS", release: "dev", revision: strings.Repeat("x", 40), info: source, want: "vcs-" + sha}, + {name: "malformed linked revision without VCS", release: "dev", revision: strings.Repeat("x", 40), want: "dev"}, + { + name: "malformed VCS falls through to installed module", + info: &debug.BuildInfo{ + Main: debug.Module{Version: "v1.2.3"}, + Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: "short"}}, + }, + want: "v1.2.3", + }, + { + name: "malformed VCS without installed module", + info: &debug.BuildInfo{ + Main: debug.Module{Version: "(devel)"}, + Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: "short"}}, + }, + want: "dev", + }, + {name: "invalid installed module", info: &debug.BuildInfo{Main: debug.Module{Version: "v1.2.3\n"}}, want: "dev"}, + {name: "placeholder installed module", info: &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}, want: "dev"}, + {name: "release whitespace", release: "v1.2.3 extra", want: "dev"}, + {name: "release newline", release: "v1.2.3\n", want: "dev"}, + {name: "release slash", release: "release/v1.2.3", want: "dev"}, + {name: "release non ASCII", release: "v1.2.3-\u00e9", want: "dev"}, + {name: "invalid release falls through to linked revision", release: "v1.2.3 extra", revision: otherSHA, info: dirty, want: "vcs-" + otherSHA}, + {name: "invalid release falls through to dirty VCS", release: "release/v1.2.3", info: dirty, want: "vcs-" + sha + "-dirty"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, resolveServerVersion(tt.release, tt.revision, tt.info)) + }) + } +} + +func TestResolveServerVersionSkipsNullRevisions(t *testing.T) { + t.Parallel() + for _, revision := range []string{strings.Repeat("0", 40), strings.Repeat("0", 64)} { + for _, modified := range []string{"false", "true"} { + for _, linked := range []bool{false, true} { + name := revision + "/modified=" + modified + if linked { + name += "/linked" + } + t.Run(name, func(t *testing.T) { + info := &debug.BuildInfo{Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: revision}, + {Key: "vcs.modified", Value: modified}, + }} + commit := "commit" + want := "dev" + if linked { + commit = revision + info.Settings[0].Value = strings.Repeat("ab", 20) + want = "vcs-" + strings.Repeat("ab", 20) + if modified == "true" { + want += "-dirty" + } + } + assert.Equal(t, want, resolveServerVersion("dev", commit, info)) + }) + } + } + } +} diff --git a/cmd/mcpcurl/README.md b/cmd/mcpcurl/README.md index e1227d585d..cf24a74358 100644 --- a/cmd/mcpcurl/README.md +++ b/cmd/mcpcurl/README.md @@ -27,7 +27,7 @@ go build -o mcpcurl ### Using Go Install ```bash -go install github.com/github/github-mcp-server/cmd/mcpcurl@latest +go install github.com/github/github-mcp-server/v2/cmd/mcpcurl@latest ``` ### Verify Installation diff --git a/docs/error-handling.md b/docs/error-handling.md index 9bb27e0fa4..d483ebdec2 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -57,6 +57,31 @@ This function: return ghErrors.NewGitHubGraphQLErrorResponse(ctx, message, err), nil ``` +### Typed Tool Results + +Tools registered with concrete `NewTool[In, Out]` types expose output schemas and +`structuredContent` when the negotiated protocol version is a supported version +`2026-07-28` or newer. Older, unknown, or omitted protocol versions retain the +original text responses without these typed-output additions. Unknown versions +are not treated as supported merely because they sort after `2026-07-28`. +Ordinary API and validation errors never expose the SDK-generated zero-value +structured output. + +Handler-provided structured statuses are distinct from generated error output. +For example, `issue_write` can return `IsError: true` with +`status: "awaiting_user_submission"` while an MCP App form is pending. Modern +clients retain that explicit status and its reason; legacy clients retain the +stop message and error flag without structured content. This status does not +mean the issue was created or updated. The output schema includes a strict +status variant alongside the mutation's `id`/`url` variant. Multi-round-trip +`InputRequests` continue to pass through unchanged. + +The consolidated issue tools use method-discriminated object outputs for modern +successes. Issue and comment data are projected from the API responses into +compact DTOs, excluding API, node, and hypermedia URLs while retaining human +links. Legacy text is marshalled separately and keeps its original format and +fields. + ### Context Management The error handling system uses context to store errors for later inspection: diff --git a/docs/feature-flags.md b/docs/feature-flags.md index f0bf7df984..2924b9f3d7 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -13,89 +13,79 @@ section in the Insiders docs](./insiders-features.md#how-feature-flags-are-resol | Method | Remote Server | Local Server | |--------|---------------|--------------| | Header | `X-MCP-Features: ,` | N/A | +| URL query parameter | `?features=,` on the server URL | N/A | | CLI flag | N/A | `--features=,` | | Environment variable | N/A | `GITHUB_FEATURES=,` | +The URL query parameter exists for clients that compose the server URL on the +user's behalf (hosted IDEs, agent platforms) and cannot set custom headers. +When both the query parameter and the header are present, the header wins — +even when its value is empty, whitespace-only, or contains only unknown flags. +The two channels are never combined. + +The complete query string is preserved during OAuth protected-resource metadata +discovery because it is part of the canonical resource identifier. Query +parameters also participate in HTTP cache keys, while MCP responses vary on +`X-MCP-Features` so a header override cannot reuse a response selected for a +different feature set. Feature names are configuration identifiers, not +secrets; as with any URL query value, they may appear in client history, proxy +logs, and server access logs. + Only flags listed in [`AllowedFeatureFlags`](../pkg/github/feature_flags.go) can be enabled by end users. Insiders-only flags are not user-toggleable. +## Declaring tool availability + +Tools, resources, and prompts use `inventory.NewFeatureRule` when feature flags +change whether they are available. Each rule declares the flags it references +and evaluates them with a fail-closed `FeatureResolver`, so normal Go boolean +expressions can represent AND, OR, NOT, and mixed conditions: + +```go +tool.FeatureRule = inventory.NewFeatureRule( + []inventory.FeatureFlag{x, y}, + func(featureAsBool inventory.FeatureResolver) bool { + return !(featureAsBool(x) && featureAsBool(y)) + }, +) +``` + +Library consumers migrating existing inventory declarations should replace +`FeatureFlagEnable`, `FeatureFlagEnableAll`, and `FeatureFlagDisable` on +`ServerTool`, `ServerResourceTemplate`, and `ServerPrompt` with `FeatureRule`. +`FeatureFlagChecker` and `ToolDependencies.IsFeatureEnabled` continue to accept +string flag names. + +Rules are evaluated lazily after request narrowing and static availability +filters. Normal Go short-circuiting avoids checks that cannot affect the result, +while one request-owned memo ensures each flag actually reached is resolved at +most once across tools, resources, prompts, and `deps.IsFeatureEnabled`. +Unavailable named tool calls skip rule evaluation but remain registered so the +handler can return the specific client-availability error. + +Feature predicates are pure and may depend only on their resolver. Construction +validates every combination of up to 16 declared flags, so an undeclared lookup +fails immediately even when ordinary evaluation would short-circuit that +branch. + +The inventory's string-based checker owns request feature state. Once installed, +that state is authoritative; a checker stored on tool dependencies is used only +as a fallback when handlers are invoked directly without request state. +Feature checkers must not call `ResolveFeature`; nested resolution fails the +owning check closed. + --- ## 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. +The list below is regenerated by comparing the default tool surface with each +user-controllable flag enabled individually. Complex multi-flag rules may +require separate documentation. 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 - - **OAuth Challenge Scopes**: `repo` - - **MCP App UI**: `ui://github-mcp-server/pr-write` - - `base`: Branch to merge into (string, required) - - `body`: PR description (string, optional) - - `draft`: Create as draft PR (boolean, optional) - - `head`: Branch containing changes (string, required) - - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) - - `title`: PR title (string, required) - -- **get_me** - Get my user profile - - **MCP App UI**: `ui://github-mcp-server/get-me` - - No parameters required - -- **issue_write** - Create or update issue/pull request - - **OAuth Challenge 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. Required 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) - - `parent_issue_number`: Issue number of the parent issue. Only used when method is 'create' and cannot be combined with issue_fields. The new issue is created and attached to this parent in the same operation. (number, optional) - - `parent_owner`: Repository owner of the parent issue. Must be provided with parent_repo. Omit both to use owner and repo. Only used when method is 'create' and parent_issue_number is provided. (string, optional) - - `parent_repo`: Repository name of the parent issue. Must be provided with parent_owner. Omit both to use owner and repo. Only used when method is 'create' and parent_issue_number is provided. (string, optional) - - `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. For updates, pass null to remove the current type. Only use if issue types are enabled for this repository. Use list_issue_types to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string | null, optional) - -- **ui_get** - Get UI data - - **OAuth Challenge Scopes**: `repo`, `read:org` - - `method`: The type of data to fetch (string, required) - - `owner`: Repository owner (required for all methods) (string, required) - - `repo`: Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers) (string, optional) - -- **update_pull_request** - Edit pull request - - **OAuth Challenge Scopes**: `repo` - - **MCP App UI**: `ui://github-mcp-server/pr-edit` - - `base`: New base branch name (string, optional) - - `body`: New description (string, optional) - - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) - - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - - `owner`: Repository owner (string, required) - - `pullNumber`: Pull request number to update (number, required) - - `repo`: Repository name (string, required) - - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) - - `state`: New state (string, optional) - - `title`: New title (string, optional) - ### `issues_granular` - **add_issue_comment_reaction** - Add Reaction to Issue or Pull Request Comment @@ -130,6 +120,27 @@ runtime behavior (such as output formatting) won't appear here. - `repo`: Repository name (string, required) - `title`: Issue title (string, required) +- **hide_issue_comment** - Hide Issue Comment + - **OAuth Challenge Scopes**: `repo` + - `classifier`: The reason for hiding the comment (string, required) + - `comment_id`: The numeric ID of the issue or pull request conversation comment (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **remove_issue_comment_reaction** - Remove Reaction from Issue or Pull Request Comment + - **OAuth Challenge Scopes**: `repo` + - `comment_id`: The issue or pull request comment ID (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `reaction_id`: The reaction ID to remove (number, required) + - `repo`: Repository name (string, required) + +- **remove_issue_reaction** - Remove Reaction from Issue or Pull Request + - **OAuth Challenge Scopes**: `repo` + - `issue_number`: The issue number (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `reaction_id`: The reaction ID to remove (number, required) + - `repo`: Repository name (string, required) + - **remove_sub_issue** - Remove Sub-Issue - **OAuth Challenge Scopes**: `repo` - `issue_number`: The parent issue number (number, required) @@ -153,6 +164,12 @@ runtime behavior (such as output formatting) won't appear here. - `owner`: Repository owner (username or organization) (string, required) - `repo`: Repository name (string, required) +- **unhide_issue_comment** - Unhide Issue Comment + - **OAuth Challenge Scopes**: `repo` + - `comment_id`: The numeric ID of the issue or pull request conversation comment (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - **update_issue_assignees** - Update Issue Assignees - **OAuth Challenge Scopes**: `repo` - `assignees`: GitHub usernames to assign to this issue. ([], required) @@ -247,6 +264,28 @@ runtime behavior (such as output formatting) won't appear here. - `pullNumber`: The pull request number (number, required) - `repo`: Repository name (string, required) +- **hide_pull_request_review** - Hide Pull Request Review + - **OAuth Challenge Scopes**: `repo` + - `classifier`: The reason for hiding the comment (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `review_id`: The numeric ID of the pull request review (number, required) + +- **hide_pull_request_review_comment** - Hide Pull Request Review Comment + - **OAuth Challenge Scopes**: `repo` + - `classifier`: The reason for hiding the comment (string, required) + - `comment_id`: The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...). (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **remove_pull_request_review_comment_reaction** - Remove Pull Request Review Comment Reaction + - **OAuth Challenge Scopes**: `repo` + - `comment_id`: The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...). (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `reaction_id`: The reaction ID to remove (number, required) + - `repo`: Repository name (string, required) + - **request_pull_request_reviewers** - Request Pull Request Reviewers - **OAuth Challenge Scopes**: `repo` - `owner`: Repository owner (username or organization) (string, required) @@ -266,6 +305,19 @@ runtime behavior (such as output formatting) won't appear here. - `pullNumber`: The pull request number (number, required) - `repo`: Repository name (string, required) +- **unhide_pull_request_review** - Unhide Pull Request Review + - **OAuth Challenge Scopes**: `repo` + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `review_id`: The numeric ID of the pull request review (number, required) + +- **unhide_pull_request_review_comment** - Unhide Pull Request Review Comment + - **OAuth Challenge Scopes**: `repo` + - `comment_id`: The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...). (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - **unresolve_review_thread** - Unresolve Review Thread - **OAuth Challenge Scopes**: `repo` - `threadID`: The node ID of the review thread to unresolve (e.g., PRRT_kwDOxxx) (string, required) @@ -350,8 +402,22 @@ runtime behavior (such as output formatting) won't appear here. - `confidence_threshold`: Minimum similarity threshold a candidate must meet to be returned; higher values are stricter. When omitted, the API's high-precision default is used. The scale is defined by the API, so no client-side bounds are enforced. (number, optional) - `issue_number`: The number of the existing issue to find duplicates for (number, required) - `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) + - `page`: Page number for pagination (min 0). Zero is forwarded for the GitHub API default. (number, optional) + - `perPage`: Results per page for pagination (min 0, max 100). Zero is forwarded for the GitHub API default. (number, optional) - `repo`: The name of the repository (string, required) +### `thread_resolution_reason` + +- **pull_request_review_write** - Write operations (create, submit, delete) on pull request reviews + - **OAuth Challenge Scopes**: `repo` + - `body`: Review comment text (string, optional) + - `commitID`: SHA of commit to review (string, optional) + - `event`: Review action to perform. (string, optional) + - `method`: The write operation to perform on pull request review. (string, required) + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number (number, required) + - `repo`: Repository name (string, required) + - `resolutionReason`: Optional reason for resolving a Copilot code review thread: addressed, wont-fix, or invalid. (string, optional) + - `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) + diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 6c941d97a1..5738252c7c 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -26,70 +26,6 @@ The list below is generated from the Go source. It covers tool **inventory and s -### `remote_mcp_ui_apps` - -- **create_pull_request** - Open new pull request - - **OAuth Challenge Scopes**: `repo` - - **MCP App UI**: `ui://github-mcp-server/pr-write` - - `base`: Branch to merge into (string, required) - - `body`: PR description (string, optional) - - `draft`: Create as draft PR (boolean, optional) - - `head`: Branch containing changes (string, required) - - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) - - `title`: PR title (string, required) - -- **get_me** - Get my user profile - - **MCP App UI**: `ui://github-mcp-server/get-me` - - No parameters required - -- **issue_write** - Create or update issue/pull request - - **OAuth Challenge 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. Required 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) - - `parent_issue_number`: Issue number of the parent issue. Only used when method is 'create' and cannot be combined with issue_fields. The new issue is created and attached to this parent in the same operation. (number, optional) - - `parent_owner`: Repository owner of the parent issue. Must be provided with parent_repo. Omit both to use owner and repo. Only used when method is 'create' and parent_issue_number is provided. (string, optional) - - `parent_repo`: Repository name of the parent issue. Must be provided with parent_owner. Omit both to use owner and repo. Only used when method is 'create' and parent_issue_number is provided. (string, optional) - - `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. For updates, pass null to remove the current type. Only use if issue types are enabled for this repository. Use list_issue_types to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string | null, optional) - -- **ui_get** - Get UI data - - **OAuth Challenge Scopes**: `repo`, `read:org` - - `method`: The type of data to fetch (string, required) - - `owner`: Repository owner (required for all methods) (string, required) - - `repo`: Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers) (string, optional) - -- **update_pull_request** - Edit pull request - - **OAuth Challenge Scopes**: `repo` - - **MCP App UI**: `ui://github-mcp-server/pr-edit` - - `base`: New base branch name (string, optional) - - `body`: New description (string, optional) - - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) - - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - - `owner`: Repository owner (string, required) - - `pullNumber`: Pull request number to update (number, required) - - `repo`: Repository name (string, required) - - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) - - `state`: New state (string, optional) - - `title`: New title (string, optional) - ### `file_blame` - **get_file_blame** - Get file blame information @@ -139,31 +75,6 @@ The list below is generated from the Go source. It covers tool **inventory and s --- -## 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. @@ -200,11 +111,18 @@ Insiders is a **meta feature flag** — the same shape as `default` or `all` for 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. + - HTTP server: `X-MCP-Features: ,` request header or a + `?features=,` server URL. Header presence takes precedence, + and the two request channels are never combined. 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. +For tool availability, functional rules declare the flags they may read and are +evaluated lazily after request narrowing. Short-circuiting skips unnecessary +checks, and request-owned state memoizes each flag that is reached. The same +state backs `deps.IsFeatureEnabled`. + `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. @@ -214,7 +132,9 @@ Insiders is a **meta feature flag** — the same shape as `default` or `all` for ### 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`. +2. Add it to `AllowedFeatureFlags` if end users should be able to opt in via + `--features`, `X-MCP-Features`, or the `features` URL query parameter. 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`. +4. For tool availability, attach an `inventory.NewFeatureRule` that declares every flag used by its predicate. For behavior inside a handler, use `deps.IsFeatureEnabled(ctx, FeatureFlagX)`. +5. Gate on concrete flags, never on `cfg.InsidersMode`. There is a `TestGitHubPackageDoesNotReadInsidersMode` guard test that fails if `pkg/github` reads `InsidersMode` directly. +6. 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 46581aa77e..888caf64a4 100644 --- a/docs/installation-guides/README.md +++ b/docs/installation-guides/README.md @@ -66,7 +66,7 @@ The GitHub MCP Server can be installed using several methods. **Docker is the mo - **Pros**: Latest features, full customization, no external dependencies - **Cons**: Requires Go development environment, more complex setup - **Prerequisites**: [Go 1.24+](https://go.dev/doc/install) -- **Build command**: `go build -o github-mcp-server cmd/github-mcp-server/main.go` +- **Build command**: `go build -o github-mcp-server ./cmd/github-mcp-server` (build the complete package from a Git checkout to retain its source revision). - **Best for**: Developers who want the latest features or need custom modifications ### Important Notes on the GitHub MCP Server diff --git a/docs/oauth-login.md b/docs/oauth-login.md index 92fc79c9df..b2016580bb 100644 --- a/docs/oauth-login.md +++ b/docs/oauth-login.md @@ -260,8 +260,8 @@ credentials, set them with `-ldflags`: ```bash go build -ldflags "\ - -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID= \ - -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret=" \ + -X github.com/github/github-mcp-server/v2/internal/buildinfo.OAuthClientID= \ + -X github.com/github/github-mcp-server/v2/internal/buildinfo.OAuthClientSecret=" \ ./cmd/github-mcp-server ``` diff --git a/docs/remote-server.md b/docs/remote-server.md index d8587a3116..be91009148 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -30,6 +30,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | 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) | +| law
`governance` | Repository governance tools for managing rulesets and custom properties at the repository, organization, and enterprise levels | https://api.githubcopilot.com/mcp/x/governance | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-governance&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgovernance%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/governance/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-governance&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgovernance%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) | diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 5ec78c6ae4..25c641153e 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -13,7 +13,7 @@ We currently support the following ways in which the GitHub MCP Server can be co | Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var | | 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 | +| Feature Flags | `X-MCP-Features` header or `?features=` URL query parameter | `--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` | @@ -345,7 +345,7 @@ As an intentional exception, content authored by trusted bot accounts (currently **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. +Insiders Mode unlocks experimental features, such as [CSV output for list tools](./insiders-features.md#csv-output-for-list-tools). 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. @@ -402,18 +402,18 @@ See [Insiders Features](./insiders-features.md) for a full list of what's availa [MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) is an extension to the Model Context Protocol that enables servers to deliver interactive user interfaces to end users. Instead of returning plain text that the LLM must interpret and relay, tools can render forms, profiles, and dashboards right in the chat. -MCP Apps is enabled by [Insiders Mode](#insiders-mode), or independently via the `remote_mcp_ui_apps` feature flag. +MCP Apps is enabled by default. Tools with MCP Apps UIs advertise them via `_meta.ui` to clients that support the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps); the metadata is omitted for clients that do not advertise the `io.modelcontextprotocol/ui` capability. To keep MCP App result views enabled while making write tools execute directly -instead of first opening an interactive form, also enable the -`mcp_apps_disable_form_deferral` feature flag. For the remote server, send both -flags in the request header: +instead of first opening an interactive form, enable the +`mcp_apps_disable_form_deferral` feature flag. For the remote server, send the +flag in the request header: ```http -X-MCP-Features: remote_mcp_ui_apps,mcp_apps_disable_form_deferral +X-MCP-Features: mcp_apps_disable_form_deferral ``` -For the local server, pass both flags to `--features`. +For the local server, pass it to `--features`. **Supported tools:** @@ -422,47 +422,10 @@ For the local server, pass both flags to `--features`. | `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) | +| `update_pull_request` | Opens an interactive form to edit a pull request | **Client requirements:** MCP Apps requires a host that supports the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps). Currently tested with VS Code (`chat.mcp.apps.enabled` setting). -
Remote ServerLocal Server
- - - - - -
Remote ServerLocal Server
- -```json -{ - "type": "http", - "url": "https://api.githubcopilot.com/mcp/", - "headers": { - "X-MCP-Features": "remote_mcp_ui_apps" - } -} -``` - - - -```json -{ - "type": "stdio", - "command": "go", - "args": [ - "run", - "./cmd/github-mcp-server", - "stdio", - "--features=remote_mcp_ui_apps" - ], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" - } -} -``` - -
- --- ### Scope Filtering diff --git a/docs/streamable-http.md b/docs/streamable-http.md index a1e6d9890a..d5fc8a5d0d 100644 --- a/docs/streamable-http.md +++ b/docs/streamable-http.md @@ -12,6 +12,22 @@ The Streamable HTTP mode enables the GitHub MCP Server to run as an HTTP service ## Running the Server +### MCP parameter headers + +For protocol version `2026-07-28` and later, the SDK validates standard +`Mcp-Method`, `Mcp-Name`, and schema-annotated `Mcp-Param-*` headers at the +Streamable HTTP boundary. Tool schemas annotate `owner` and `repo` with +`x-mcp-header`; when an annotated argument is present and non-null, its header +must be present and match the body value. A header for an absent or null +argument is rejected. Header names are case-insensitive; string values and +JSON property names are case-sensitive. + +Mismatches return HTTP `400` with JSON-RPC error code `-32020` before MCP +receiving middleware or tool handlers run. This applies to typed and untyped +tools without changing their advertised schemas. Full typed body validation +and defaults still run after tool authorization guards. Older protocol versions +do not require or validate these standard parameter headers. + ### Basic HTTP Server Start the server on the default port (8082): @@ -72,13 +88,23 @@ The OAuth protected resource metadata's `resource` attribute will be populated w ], "scopes_supported": [ "repo", - ... + "read:org", + "read:user", + "user:email", + "read:packages", + "write:packages", + "read:project", + "project", + "gist", + "notifications" ], ... } ``` This allows OAuth clients to discover authentication requirements and endpoint information automatically. +Scopes excluded from this default set, such as `delete_repo`, are requested only +through a per-tool OAuth authorization challenge when needed. The HTTP server is the OAuth protected resource, not the authorization server. It therefore serves `/.well-known/oauth-protected-resource` but does not serve diff --git a/docs/typed-tool-schemas.md b/docs/typed-tool-schemas.md new file mode 100644 index 0000000000..2065872be0 --- /dev/null +++ b/docs/typed-tool-schemas.md @@ -0,0 +1,72 @@ +# Typed tool schemas + +Typed tool registrations use concrete Go input and output types with the MCP +Go SDK's `mcp.AddTool` path. Inventory infers missing schemas and validates +arguments against the cached input schema before the handler runs; the SDK +adapts and validates typed output. Keep business rules that JSON Schema cannot express +in the handler or a preflight callback. + +Input inference unwraps one pointer level, matching the SDK's object input +contract. Output pointers retain nullable schemas and may return JSON null. + +```go +tool := github.NewToolWithSchemaOptions[workflowInput, workflowOutput]( + toolset, + mcp.Tool{Name: "list_workflow_runs"}, + scopeAccess, + inventory.TypedSchemaOptions{ + InputEnums: []inventory.SchemaEnum{ + {Path: "status", Values: github.WorkflowStatusValues()}, + }, + }, + listWorkflowRuns, +) +``` + +`SchemaEnum.Path` addresses inferred properties with dot-separated names and +uses `items` to descend into array elements. `EnumSchema` creates a standalone +string enum schema, while `WithEnum` returns a cloned explicit schema with an +enum applied at a path. Inferred and explicit schema helpers cache immutable +schemas; do not mutate the returned pointers. + +When compatibility requires a broader runtime input contract than the one +advertised to clients, provide `ValidationInputSchema`. The tool's declared +`InputSchema` remains visible while inventory validates calls against the +runtime-only schema. Use `Preflight` for checks that need raw arguments or +request dependencies before typed decoding; it may return a derived context +for the handler. Input normalizers are only for compatibility transformations, +not a replacement for schema validation. + +Inventory applies defaults from the runtime input schema before decoding. If an +omitted field must remain omitted, build the runtime schema with +`inventory.CloneSchemaWithoutDefaults` before applying validation-only +changes. Use `inventory.CloneSchema` when deriving other runtime-only schema +variants so numeric bound pointers and nested metadata are detached as well as +subschemas. Default removal visits each child once per parent. The advertised +schema can retain its defaults; the constructor caches the runtime schema +without mutating either caller-owned schema. + +Availability and authorization guards run before preflight, normalization, +and input validation. A guard or preflight result is passed through the +registered SDK handler without decoding the original arguments or invoking +user code, so the SDK still finalizes multi-round-trip results with +`resultType: "input_required"` (or `"complete"`). The permissive SDK input +envelope used for this handoff is never advertised and does not replace the +original validation schema for real calls. + +The output schema and `structuredContent` are exposed only for a negotiated, +SDK-supported protocol version `2026-07-28` or later. Unknown versions are +treated as legacy, including unsupported future dates; older, absent, and +malformed versions also retain the legacy text result. Normal `Inventory.RegisterTools` and +`ServerTool.RegisterFunc` registrations select behavior per request. Use +`RegisterToolsForProtocolEra` or `RegisterFuncForProtocolEra` only when the +protocol era is already known before server construction, such as a stateless +request-scoped server. + +If a handler intentionally returns non-JSON text or content blocks, set +`PreserveHandlerContent` in `TypedSchemaOptions`, or call +`inventory.PreserveToolHandlerContent(ctx)` from the handler middleware for +request-dependent output such as CSV. This is for intentional non-DTO responses +such as resources, errors, or CSV; ordinary declared structured success must +retain shared DTO serialization, with modern JSON text equal to +`structuredContent`, even when legacy handler text was a plain mutation message. diff --git a/e2e/README.md b/e2e/README.md index 62730431ad..08f26850f2 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -73,7 +73,7 @@ Running the tests: --- PASS: TestE2E/Initialize (0.09s) --- FAIL: TestE2E/CallTool_get_me (0.46s) FAIL -FAIL github.com/github/github-mcp-server/e2e 1.433s +FAIL github.com/github/github-mcp-server/v2/e2e 1.433s FAIL ``` diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 82075ec55c..eb32972650 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net/http" "os" "os/exec" @@ -15,10 +16,11 @@ import ( "testing" "time" - "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/v89/github" + "github.com/github/github-mcp-server/v2/internal/ghmcp" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + gogithub "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) @@ -62,21 +64,116 @@ func getE2EHost() string { } func getRESTClient(t *testing.T) *gogithub.Client { - // Get token and ensure Docker image is built - token := getE2EToken(t) + ghClient, err := newRESTClient(getE2EToken(t), getE2EHost()) + require.NoError(t, err, "expected to create GitHub client successfully") - // Create a new GitHub client with the token - ghClient := gogithub.NewClient(nil).WithAuthToken(token) + return ghClient +} - if host := getE2EHost(); host != "" && host != "https://github.com" { - var err error - // Currently this works for GHEC because the API is exposed at the api subdomain and the path prefix - // but it would be preferable to extract the host parsing from the main server logic, and use it here. - ghClient, err = ghClient.WithEnterpriseURLs(host, host) - require.NoError(t, err, "expected to create GitHub client with host") +func newRESTClient(token, host string) (*gogithub.Client, error) { + apiHost, err := utils.NewAPIHost(host) + if err != nil { + return nil, fmt.Errorf("failed to parse API host: %w", err) } - return ghClient + 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) + } + + return gogithub.NewClient( + gogithub.WithAuthToken(token), + gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()), + ) +} + +func TestRESTClientURLs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + host string + wantBaseURL string + wantUploadURL string + }{ + { + name: "dotcom default", + wantBaseURL: "https://api.github.com/", + wantUploadURL: "https://uploads.github.com/", + }, + { + name: "dotcom explicit", + host: "https://github.com", + wantBaseURL: "https://api.github.com/", + wantUploadURL: "https://uploads.github.com/", + }, + { + name: "GHEC", + host: "https://example.ghe.com", + wantBaseURL: "https://api.example.ghe.com/", + wantUploadURL: "https://uploads.example.ghe.com/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client, err := newRESTClient("test-token", tt.host) + require.NoError(t, err) + require.Equal(t, tt.wantBaseURL, client.BaseURL()) + require.Equal(t, tt.wantUploadURL, client.UploadURL()) + }) + } +} + +func TestInProcessStdioServer(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + server, err := ghmcp.NewStdioMCPServer(ctx, github.MCPServerConfig{ + Version: "e2e-test", + Token: "test-token", + EnabledToolsets: []string{"context"}, + Translator: translations.NullTranslationHelper, + Logger: slog.New(slog.DiscardHandler), + }) + require.NoError(t, err) + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverErr := make(chan error, 1) + go func() { + serverErr <- server.Run(ctx, serverTransport) + }() + + client := mcp.NewClient(&mcp.Implementation{ + Name: "e2e-test-client", + Version: "0.0.1", + }, nil) + session, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + + tools, err := session.ListTools(ctx, nil) + require.NoError(t, err) + require.True(t, slices.ContainsFunc(tools.Tools, func(tool *mcp.Tool) bool { + return tool.Name == "get_me" + })) + + require.NoError(t, session.Close()) + select { + case err := <-serverErr: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("timed out waiting for the in-process MCP server to stop") + } } // waitForRateLimit checks the current rate limit and waits if necessary. @@ -221,11 +318,13 @@ func setupMCPClient(t *testing.T, options ...clientOption) *mcp.ClientSession { enabledToolsets = github.GetDefaultToolsetIDs() } - ghServer, err := ghmcp.NewMCPServer(ghmcp.MCPServerConfig{ + ghServer, err := ghmcp.NewStdioMCPServer(ctx, github.MCPServerConfig{ + Version: "e2e-test", Token: token, EnabledToolsets: enabledToolsets, Host: getE2EHost(), Translator: translations.NullTranslationHelper, + Logger: slog.New(slog.DiscardHandler), }) require.NoError(t, err, "expected to construct MCP server successfully") @@ -968,7 +1067,7 @@ func TestRequestCopilotReview(t *testing.T) { // Cleanup the repository after the test t.Cleanup(func() { // MCP Server doesn't support deletions, but we can use the GitHub Client - ghClient := gogithub.NewClient(nil).WithAuthToken(getE2EToken(t)) + ghClient := getRESTClient(t) t.Logf("Deleting repository %s/%s...", currentOwner, repoName) _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName) require.NoError(t, err, "expected to delete repository successfully") @@ -1063,9 +1162,9 @@ func TestRequestCopilotReview(t *testing.T) { // Finally, get requested reviews and see copilot is in there // MCP Server doesn't support requesting reviews yet, but we can use the GitHub Client - ghClient := gogithub.NewClient(nil).WithAuthToken(getE2EToken(t)) + ghClient := getRESTClient(t) t.Logf("Getting reviews for pull request in %s/%s...", currentOwner, repoName) - reviewRequests, _, err := ghClient.PullRequests.ListReviewers(context.Background(), currentOwner, repoName, 1, nil) + reviewRequests, _, err := ghClient.PullRequests.ListReviewers(context.Background(), currentOwner, repoName, 1) require.NoError(t, err, "expected to get review requests successfully") // Check if Copilot was added as a reviewer - skip if not available diff --git a/go.mod b/go.mod index 45e0dcebdc..ea0cd5eb9d 100644 --- a/go.mod +++ b/go.mod @@ -1,25 +1,27 @@ -module github.com/github/github-mcp-server +module github.com/github/github-mcp-server/v2 -go 1.25.12 +go 1.26.8 require ( github.com/go-chi/chi/v5 v5.3.2 github.com/go-viper/mapstructure/v2 v2.5.0 - github.com/google/go-github/v89 v89.0.1-0.20260728185857-34349a88bac3 + github.com/google/go-github/v92 v92.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.7.0 + github.com/modelcontextprotocol/go-sdk v1.8.0 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/segmentio/encoding v0.5.4 + github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed + github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf 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.12.1 github.com/yosida95/uritemplate/v3 v3.0.2 - golang.org/x/oauth2 v0.36.0 + golang.org/x/net v0.59.0 + golang.org/x/oauth2 v0.37.0 ) require ( @@ -31,16 +33,14 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect 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/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect ) diff --git a/go.sum b/go.sum index 4974f0c247..bfa3c4dbae 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs 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/v89 v89.0.1-0.20260728185857-34349a88bac3 h1:0a/p9KtPso8UBauBD/p9Go1oaZrrEydBNHjaaKkSHJo= -github.com/google/go-github/v89 v89.0.1-0.20260728185857-34349a88bac3/go.mod h1:QLcbU0ipeAqQuR5KSg8c2lql4Qk1EwJ2dWz/0rP4Nho= +github.com/google/go-github/v92 v92.0.0 h1:4vW4RVffwIvoEfIA4RX09mRSB47qQX557/2+HgeWRRg= +github.com/google/go-github/v92 v92.0.0/go.mod h1:w3CH62ZcmRfvW1cdXpyTztSOVMtdjxtKpgo0GouLmjY= 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= @@ -34,8 +34,8 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8 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.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= -github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= +github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021/go.mod h1:WERUkUryfUWlrHnFSO/BEUZ+7Ns8aZy7iVOGewxKzcc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= @@ -49,10 +49,10 @@ 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= -github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= +github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed h1:KT7hI8vYXgU0s2qaMkrfq9tCA1w/iEPgfredVP+4Tzw= +github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= +github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf h1:o1uxfymjZ7jZ4MsgCErcwWGtVKSiNAXtS59Lhs6uI/g= +github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -86,23 +86,23 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL 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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= 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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= 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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= 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= @@ -111,15 +111,15 @@ 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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= 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= diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index cd5084fa29..7e9f55d813 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -6,7 +6,7 @@ // // Example: // -// go build -ldflags="-X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=xxx" +// go build -ldflags="-X github.com/github/github-mcp-server/v2/internal/buildinfo.OAuthClientID=xxx" package buildinfo // OAuthClientID is the default OAuth client ID, set at build time. Empty in diff --git a/internal/ghmcp/oauth.go b/internal/ghmcp/oauth.go index 3648189217..c363da2e50 100644 --- a/internal/ghmcp/oauth.go +++ b/internal/ghmcp/oauth.go @@ -8,8 +8,8 @@ import ( "log/slog" "strings" - "github.com/github/github-mcp-server/internal/oauth" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/internal/oauth" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/internal/ghmcp/oauth_test.go b/internal/ghmcp/oauth_test.go index e5a29d6b27..fc6b37b17f 100644 --- a/internal/ghmcp/oauth_test.go +++ b/internal/ghmcp/oauth_test.go @@ -10,9 +10,9 @@ import ( "net/url" "testing" - "github.com/github/github-mcp-server/internal/oauth" - "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/v2/internal/oauth" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/http/headers" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" diff --git a/internal/ghmcp/oauth_typed_test.go b/internal/ghmcp/oauth_typed_test.go new file mode 100644 index 0000000000..e91e2f45aa --- /dev/null +++ b/internal/ghmcp/oauth_typed_test.go @@ -0,0 +1,449 @@ +package ghmcp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/github/github-mcp-server/v2/internal/oauth" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type oauthProbeOutput struct { + Status string `json:"status"` +} + +func oauthRegisteredSession( + t *testing.T, + typed bool, + fake oauthAuthenticator, + protocol string, + options *mcp.ClientOptions, + toolCalls *int, + captures ...chan json.RawMessage, +) *mcp.ClientSession { + t.Helper() + server := mcp.NewServer(&mcp.Implementation{Name: "oauth-test", Version: "test"}, nil) + middleware := createOAuthToolMiddleware(fake, discardLogger()) + if typed { + tool := inventory.NewServerToolWithContextHandler( + mcp.Tool{Name: probeToolName}, + inventory.ToolsetMetadata{ID: "test"}, + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, oauthProbeOutput, error) { + (*toolCalls)++ + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "tool-ran"}}}, + oauthProbeOutput{Status: "tool-ran"}, nil + }, + ) + tool.RegisterFunc(server, nil, middleware) + } else { + server.AddTool(&mcp.Tool{Name: probeToolName, InputSchema: &jsonschema.Schema{Type: "object"}}, + middleware(func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + (*toolCalls)++ + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "tool-ran"}}}, nil + })) + } + return connectOAuthRegisteredServer(t, server, protocol, options, captures...) +} + +func connectOAuthRegisteredServer(t *testing.T, server *mcp.Server, protocol string, options *mcp.ClientOptions, captures ...chan json.RawMessage) *mcp.ClientSession { + t.Helper() + st, ct := mcp.NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = ss.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "oauth-client", Version: "test"}, options) + var transport mcp.Transport = ct + if len(captures) > 0 { + transport = oauthCaptureTransport{Transport: ct, responses: captures[0]} + } + cs, err := client.Connect(context.Background(), transport, &mcp.ClientSessionOptions{ProtocolVersion: protocol}) + require.NoError(t, err) + t.Cleanup(func() { _ = cs.Close() }) + return cs +} + +type oauthCaptureTransport struct { + mcp.Transport + responses chan json.RawMessage +} + +func (t oauthCaptureTransport) Connect(ctx context.Context) (mcp.Connection, error) { + connection, err := t.Transport.Connect(ctx) + if err != nil { + return nil, err + } + return oauthCaptureConnection{Connection: connection, responses: t.responses}, nil +} + +type oauthCaptureConnection struct { + mcp.Connection + responses chan json.RawMessage +} + +func (c oauthCaptureConnection) Read(ctx context.Context) (jsonrpc.Message, error) { + message, err := c.Connection.Read(ctx) + if err == nil { + if response, ok := message.(*jsonrpc.Response); ok && response.Result != nil { + c.responses <- append(json.RawMessage(nil), response.Result...) + } + } + return message, err +} + +func oauthPendingAuthenticator() *fakeAuthenticator { + return &fakeAuthenticator{ + outcome: &oauth.Outcome{ + UserAction: &oauth.UserAction{URL: "https://example.com/auth", Message: "Authorize, then retry."}, + FlowID: "flow-1", + }, + tokenAfterAwait: true, + cancelResult: true, + } +} + +func TestOAuthTypedRegistration(t *testing.T) { + for _, typed := range []bool{false, true} { + name := "untyped-control" + if typed { + name = "typed" + } + t.Run(name, func(t *testing.T) { + urlCaps := &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}} + t.Run("wire-input-required", func(t *testing.T) { + fake := oauthPendingAuthenticator() + calls := 0 + captures := make(chan json.RawMessage, 8) + session := oauthRegisteredSession(t, typed, fake, "", &mcp.ClientOptions{ + Capabilities: urlCaps, MultiRoundTrip: &mcp.MultiRoundTripOptions{Disabled: true}, + }, &calls, captures) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: probeToolName}) + require.NoError(t, err) + <-captures // initialize + wire := <-captures + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(wire, &fields)) + assert.JSONEq(t, `"input_required"`, string(fields["resultType"]), "wire: %s", wire) + assert.True(t, result.NeedsInput()) + assert.Contains(t, result.InputRequests, oauthElicitIDPrefix+"flow-1") + assert.Nil(t, result.StructuredContent) + assert.Zero(t, calls) + t.Logf("raw tools/call response: %s", wire) + }) + for _, action := range []string{"accept", "decline"} { + t.Run(action, func(t *testing.T) { + fake := oauthPendingAuthenticator() + calls, prompts := 0, 0 + session := oauthRegisteredSession(t, typed, fake, "", &mcp.ClientOptions{ + Capabilities: urlCaps, + ElicitationHandler: func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompts++ + assert.Equal(t, "url", req.Params.Mode) + assert.Equal(t, "https://example.com/auth", req.Params.URL) + return &mcp.ElicitResult{Action: action}, nil + }, + }, &calls) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: probeToolName}) + require.NoError(t, err) + assert.Equal(t, 1, prompts) + require.Len(t, result.Content, 1) + assert.False(t, result.NeedsInput()) + wire, err := json.Marshal(result) + require.NoError(t, err) + assert.Contains(t, string(wire), `"resultType":"complete"`) + if action == "accept" { + assert.Equal(t, 1, calls) + assert.Equal(t, 1, fake.awaitCalls) + assert.Equal(t, "flow-1", fake.lastAwaitFlowID) + assert.Zero(t, fake.cancelCalls) + if typed { + output, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, `{"status":"tool-ran"}`, string(output)) + } + } else { + assert.Zero(t, calls) + assert.Zero(t, fake.awaitCalls) + assert.Equal(t, 1, fake.cancelCalls) + assert.Contains(t, result.Content[0].(*mcp.TextContent).Text, "declined") + assert.Nil(t, result.StructuredContent) + } + }) + } + for _, protocol := range []string{"", "2025-11-25"} { + t.Run("manual-fallback/"+protocol, func(t *testing.T) { + fake := oauthPendingAuthenticator() + calls := 0 + session := oauthRegisteredSession(t, typed, fake, protocol, &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{}, + }, &calls) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: probeToolName}) + require.NoError(t, err) + require.Len(t, result.Content, 1) + assert.Equal(t, "Authorize, then retry.", result.Content[0].(*mcp.TextContent).Text) + assert.False(t, result.NeedsInput()) + assert.Nil(t, result.StructuredContent) + assert.Zero(t, calls) + assert.Zero(t, fake.awaitCalls) + assert.Zero(t, fake.cancelCalls) + assert.Equal(t, protocol != "", fake.lastPrompter != nil) + }) + } + }) + } +} + +type legacyPromptAuthenticator struct { + fakeAuthenticator +} + +func (f *legacyPromptAuthenticator) Authenticate(ctx context.Context, prompter oauth.Prompter) (*oauth.Outcome, error) { + f.authCalls++ + f.lastPrompter = prompter + if err := prompter.PromptURL(ctx, oauth.Prompt{URL: "https://example.com/auth", Message: "Authorize"}); err != nil { + return nil, err + } + f.hasToken = true + return nil, nil +} + +func TestOAuthTypedRegistrationLegacyElicitation(t *testing.T) { + for _, typed := range []bool{false, true} { + t.Run(map[bool]string{false: "untyped-control", true: "typed"}[typed], func(t *testing.T) { + fake := &legacyPromptAuthenticator{} + calls, prompts := 0, 0 + session := oauthRegisteredSession(t, typed, fake, "2025-11-25", &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}}, + ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompts++ + return &mcp.ElicitResult{Action: "accept"}, nil + }, + }, &calls) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: probeToolName}) + require.NoError(t, err) + assert.Equal(t, 1, prompts) + assert.Equal(t, 1, calls) + assert.Equal(t, 1, fake.authCalls) + assert.False(t, result.NeedsInput()) + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1) + assert.Equal(t, "tool-ran", result.Content[0].(*mcp.TextContent).Text) + }) + } +} + +func TestOAuthTypedHTTPHeaderBoundary(t *testing.T) { + for _, tc := range []struct { + name string + arguments string + owner string + repo string + mismatch bool + valid bool + }{ + {"owner-mismatch", `{"owner":"octo","repo":"hello","mode":"valid"}`, "other", "hello", true, true}, + {"repo-mismatch", `{"owner":"octo","repo":"hello","mode":"valid"}`, "octo", "other", true, true}, + {"missing-header", `{"owner":"octo","repo":"hello","mode":"valid"}`, "", "hello", true, true}, + {"matching", `{"owner":"octo","repo":"hello","mode":"valid"}`, "octo", "hello", false, true}, + {"missing-required", `{}`, "", "", false, false}, + {"invalid-enum", `{"owner":"octo","repo":"hello","mode":"invalid"}`, "octo", "hello", false, false}, + {"invalid-owner-type", `{"owner":42,"repo":"hello","mode":"valid"}`, "42", "hello", false, false}, + {"null-owner", `{"owner":null,"repo":"hello","mode":"valid"}`, "", "hello", false, false}, + {"non-object", `[]`, "", "", false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + fake := oauthPendingAuthenticator() + handlerCalls, receivingCalls, preflightCalls, normalizerCalls := 0, 0, 0, 0 + type input struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Mode string `json:"mode"` + } + tool := inventory.NewServerToolWithContextHandlerAndSchemaOptions( + mcp.Tool{ + Name: probeToolName, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + "repo": {Type: "string"}, + "mode": {Type: "string", Enum: []any{"valid"}}, + }, + Required: []string{"owner", "repo", "mode"}, + }, + }, + inventory.ToolsetMetadata{ID: "test"}, + func(context.Context, *mcp.CallToolRequest, input) (*mcp.CallToolResult, oauthProbeOutput, error) { + handlerCalls++ + return nil, oauthProbeOutput{Status: "tool-ran"}, nil + }, + inventory.TypedSchemaOptions{ + Preflight: func(ctx context.Context, _ *mcp.CallToolRequest) (context.Context, *mcp.CallToolResult, error) { + preflightCalls++ + return ctx, nil, nil + }, + }, + func(raw json.RawMessage) (json.RawMessage, error) { + normalizerCalls++ + return raw, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "oauth-http", Version: "test"}, nil) + tool.RegisterFunc(server, nil, createOAuthToolMiddleware(fake, discardLogger())) + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if method == inventory.MCPMethodToolsCall { + receivingCalls++ + } + return next(ctx, method, req) + } + }) + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return server + }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) + call := func(accept bool) *httptest.ResponseRecorder { + t.Helper() + params := map[string]any{ + "name": probeToolName, "arguments": json.RawMessage(tc.arguments), + "_meta": mcp.Meta{ + mcp.MetaKeyProtocolVersion: inventory.ProtocolVersionMultiRoundTrip, + mcp.MetaKeyClientInfo: &mcp.Implementation{Name: "test", Version: "test"}, + mcp.MetaKeyClientCapabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}}, + }, + } + if accept { + params["inputResponses"] = mcp.InputResponseMap{ + oauthElicitIDPrefix + "flow-1": &mcp.ElicitResult{Action: "accept"}, + } + } + body, err := json.Marshal(params) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(fmt.Sprintf( + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":%s}`, body))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Mcp-Protocol-Version", inventory.ProtocolVersionMultiRoundTrip) + req.Header.Set("Mcp-Method", "tools/call") + req.Header.Set("Mcp-Name", probeToolName) + req.Header.Set("Mcp-Param-owner", tc.owner) + req.Header.Set("Mcp-Param-repo", tc.repo) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec + } + rec := call(false) + if tc.mismatch { + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "header mismatch") + assert.Zero(t, receivingCalls) + assert.Zero(t, fake.authCalls) + } else { + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"resultType":"input_required"`) + assert.NotContains(t, rec.Body.String(), "structuredContent") + assert.Equal(t, 1, receivingCalls) + assert.Equal(t, 1, fake.authCalls) + } + assert.Zero(t, handlerCalls) + assert.Zero(t, preflightCalls) + assert.Zero(t, normalizerCalls) + if tc.mismatch { + return + } + rec = call(true) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"resultType":"complete"`) + assert.NotContains(t, rec.Body.String(), `"resultType":"input_required"`) + assert.Equal(t, 1, fake.awaitCalls) + assert.Equal(t, 1, preflightCalls) + assert.Equal(t, 1, normalizerCalls) + if tc.valid { + assert.Equal(t, 1, handlerCalls) + assert.Contains(t, rec.Body.String(), `"structuredContent":{"status":"tool-ran"}`) + } else { + assert.Zero(t, handlerCalls) + assert.Contains(t, rec.Body.String(), `"isError":true`) + assert.NotContains(t, rec.Body.String(), "structuredContent") + } + }) + } +} + +func TestOAuthTypedRegistrationGuardsInvalidArguments(t *testing.T) { + type input struct { + Mode string `json:"mode"` + } + for _, raw := range []string{ + `{}`, `{"mode":"invalid"}`, `{"mode":42}`, `[]`, `null`, `"not an object"`, + } { + t.Run(raw, func(t *testing.T) { + arguments := json.RawMessage(raw) + fake := oauthPendingAuthenticator() + handlerCalls, preflightCalls, normalizerCalls := 0, 0, 0 + tool := inventory.NewServerToolWithContextHandlerAndSchemaOptions( + mcp.Tool{ + Name: probeToolName, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"mode": {Type: "string", Enum: []any{"valid"}}}, + Required: []string{"mode"}, + }, + }, + inventory.ToolsetMetadata{ID: "test"}, + func(context.Context, *mcp.CallToolRequest, input) (*mcp.CallToolResult, oauthProbeOutput, error) { + handlerCalls++ + return nil, oauthProbeOutput{Status: "unexpected"}, nil + }, + inventory.TypedSchemaOptions{ + Preflight: func(ctx context.Context, _ *mcp.CallToolRequest) (context.Context, *mcp.CallToolResult, error) { + preflightCalls++ + return ctx, nil, nil + }, + }, + func(raw json.RawMessage) (json.RawMessage, error) { + normalizerCalls++ + return raw, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "test"}, nil) + tool.RegisterFunc(server, nil, createOAuthToolMiddleware(fake, discardLogger())) + session := connectOAuthRegisteredServer(t, server, "", &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}}, + MultiRoundTrip: &mcp.MultiRoundTripOptions{Disabled: true}, + }) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: probeToolName, Arguments: arguments}) + require.NoError(t, err) + assert.True(t, result.NeedsInput(), "auth must win over invalid arguments") + assert.Zero(t, handlerCalls) + assert.Zero(t, preflightCalls) + assert.Zero(t, normalizerCalls) + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: probeToolName, Arguments: arguments, + InputResponses: mcp.InputResponseMap{oauthElicitIDPrefix + "flow-1": &mcp.ElicitResult{Action: "accept"}}, + }) + require.NoError(t, err) + assert.True(t, result.IsError, "authorized retries must enforce original input schema") + assert.False(t, result.NeedsInput()) + assert.Zero(t, handlerCalls) + assert.Equal(t, 1, preflightCalls) + assert.Equal(t, 1, normalizerCalls) + assert.Equal(t, 1, fake.awaitCalls) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + encoded, err := json.Marshal(list.Tools[0].InputSchema) + require.NoError(t, err) + assert.Contains(t, string(encoded), `"required":["mode"]`) + assert.Contains(t, string(encoded), `"enum":["valid"]`) + }) + } +} diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index dadc05744b..c826b6d0ea 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -8,37 +8,36 @@ import ( "net/http" "os" "os/signal" + "strconv" "strings" "syscall" "time" - "github.com/github/github-mcp-server/internal/oauth" - "github.com/github/github-mcp-server/internal/requeststate" - "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" - "github.com/github/github-mcp-server/pkg/utils" - gogithub "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/internal/oauth" + "github.com/github/github-mcp-server/v2/internal/requeststate" + "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/http/transport" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/lockdown" + mcplog "github.com/github/github-mcp-server/v2/pkg/log" + "github.com/github/github-mcp-server/v2/pkg/observability" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/raw" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + gogithub "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) // githubClients holds all the GitHub API clients created for a server instance. type githubClients struct { - rest *gogithub.Client - restUATransp *transport.UserAgentTransport - gql *githubv4.Client - gqlHTTP *http.Client // retained for middleware to modify transport - raw *raw.Client - repoAccess *lockdown.RepoAccessCache + rest *gogithub.Client + gql *githubv4.Client + raw *raw.Client + repoAccess *lockdown.RepoAccessCache } // createGitHubClients creates all the GitHub API clients needed by the server. @@ -90,7 +89,7 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv // client per request (see pkg/github RequestDeps) and does not use this path. restUATransport := &transport.UserAgentTransport{ Transport: &transport.ETagTransport{Transport: http.DefaultTransport}, - Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version), + Agent: stdioUserAgent(cfg, nil), } restClient, err := newRESTClient(cfg, restUATransport, restURL.String(), uploadURL.String(), allowedHosts) if err != nil { @@ -102,7 +101,10 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv gqlHTTPClient := &http.Client{ Transport: &transport.BearerAuthTransport{ Transport: &transport.GraphQLFeaturesTransport{ - Transport: http.DefaultTransport, + Transport: &transport.UserAgentTransport{ + Transport: http.DefaultTransport, + Agent: stdioUserAgent(cfg, nil), + }, }, Token: cfg.Token, TokenProvider: cfg.TokenProvider, @@ -117,7 +119,7 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv // be large and are streamed rather than retained in memory. rawUATransport := &transport.UserAgentTransport{ Transport: http.DefaultTransport, - Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version), + Agent: stdioUserAgent(cfg, nil), } rawRESTClient, err := newRESTClient(cfg, rawUATransport, restURL.String(), uploadURL.String(), allowedHosts) if err != nil { @@ -141,12 +143,10 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv } return &githubClients{ - rest: restClient, - restUATransp: restUATransport, - gql: gqlClient, - gqlHTTP: gqlHTTPClient, - raw: rawClient, - repoAccess: repoAccessCache, + rest: restClient, + gql: gqlClient, + raw: rawClient, + repoAccess: repoAccessCache, }, nil } @@ -232,7 +232,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se return nil, fmt.Errorf("failed to create GitHub MCP server: %w", err) } - ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.restUATransp, clients.gqlHTTP)) + ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg)) return ghServer, nil } @@ -256,7 +256,7 @@ type StdioServerConfig struct { EnabledTools []string // EnabledFeatures is a list of feature flags that are enabled - // Items with FeatureFlagEnable matching an entry in this list will be available + // Tool feature rules evaluate entries in this list. EnabledFeatures []string // ReadOnly indicates if we should only register read-only tools @@ -441,36 +441,28 @@ func createFeatureChecker(enabledFeatures []string, insidersMode bool) inventory } } -func addUserAgentsMiddleware(cfg github.MCPServerConfig, restUATransp *transport.UserAgentTransport, gqlHTTPClient *http.Client) func(next mcp.MethodHandler) mcp.MethodHandler { +func stdioUserAgent(cfg github.MCPServerConfig, client *mcp.Implementation) string { + agent := fmt.Sprintf("github-mcp-server/%s", cfg.Version) + if client != nil { + comment := strconv.Quote(client.Name + "/" + client.Version) + comment = comment[1 : len(comment)-1] + comment = strings.NewReplacer("(", `\(`, ")", `\)`).Replace(comment) + agent += " (" + comment + ")" + } + if cfg.InsidersMode { + agent += " (insiders)" + } + return agent +} + +func addUserAgentsMiddleware(cfg github.MCPServerConfig) 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" { - return next(ctx, method, request) - } - - initializeRequest, ok := request.(*mcp.InitializeRequest) - if !ok { - return next(ctx, method, request) + var client *mcp.Implementation + if info, ok := request.(interface{ ClientInfo() *mcp.Implementation }); ok { + client = info.ClientInfo() } - - message := initializeRequest - userAgent := fmt.Sprintf( - "github-mcp-server/%s (%s/%s)", - cfg.Version, - message.Params.ClientInfo.Name, - message.Params.ClientInfo.Version, - ) - if cfg.InsidersMode { - userAgent += " (insiders)" - } - - restUATransp.Agent = userAgent - - gqlHTTPClient.Transport = &transport.UserAgentTransport{ - Transport: gqlHTTPClient.Transport, - Agent: userAgent, - } - + ctx = transport.WithUserAgent(ctx, stdioUserAgent(cfg, client)) return next(ctx, method, request) } } diff --git a/internal/ghmcp/server_test.go b/internal/ghmcp/server_test.go index 6f0e3ac3f3..1d6f844c41 100644 --- a/internal/ghmcp/server_test.go +++ b/internal/ghmcp/server_test.go @@ -1 +1,251 @@ package ghmcp + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/observability" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateGitHubClientsGraphQLUserAgent(t *testing.T) { + t.Parallel() + for _, insiders := range []bool{false, true} { + t.Run(map[bool]string{false: "release", true: "insiders"}[insiders], func(t *testing.T) { + headers := make(chan http.Header, 1) + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers <- r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"viewer":{"login":"octocat"}}}`)) + })) + defer api.Close() + cfg := github.MCPServerConfig{Version: "v1.2.3", Token: "fixture-token", InsidersMode: insiders} + clients, err := createGitHubClients(cfg, newStaticAPIHostResolver(t, api.URL)) + require.NoError(t, err) + var query struct { + Viewer struct{ Login string } + } + require.NoError(t, clients.gql.Query(t.Context(), &query, nil)) + want := "github-mcp-server/v1.2.3" + if insiders { + want += " (insiders)" + } + header := <-headers + assert.Equal(t, want, header.Get("User-Agent")) + assert.Equal(t, "Bearer fixture-token", header.Get("Authorization")) + }) + } +} + +func TestStdioGraphQLUserAgent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + handshake string + clientInfo *mcp.Implementation + version string + insiders bool + want string + }{ + { + name: "SDK discovery", handshake: "sdk", version: "v1.2.3", + clientInfo: &mcp.Implementation{Name: "test-client", Version: "4.5.6"}, + want: "github-mcp-server/v1.2.3 (test-client/4.5.6)", + }, + { + name: "direct modern call", version: "v1.2.3", + clientInfo: &mcp.Implementation{Name: "test-client", Version: "4.5.6"}, + want: "github-mcp-server/v1.2.3 (test-client/4.5.6)", + }, + { + name: "modern call without optional client info", version: "v1.2.3", + want: "github-mcp-server/v1.2.3", + }, + { + name: "legacy initialize", handshake: "legacy", version: "v1.2.3", + clientInfo: &mcp.Implementation{Name: "test-client", Version: "4.5.6"}, + want: "github-mcp-server/v1.2.3 (test-client/4.5.6)", + }, + { + name: "insiders", version: "v1.2.3", insiders: true, + clientInfo: &mcp.Implementation{Name: "test-client", Version: "4.5.6"}, + want: "github-mcp-server/v1.2.3 (test-client/4.5.6) (insiders)", + }, + { + name: "different server build", version: "v1.2.4", + clientInfo: &mcp.Implementation{Name: "test-client", Version: "4.5.6"}, + want: "github-mcp-server/v1.2.4 (test-client/4.5.6)", + }, + { + name: "HTTP comment delimiters", version: "v1.2.3", + clientInfo: &mcp.Implementation{Name: `Editor (Preview) \client`, Version: `v(1)\build`}, + want: `github-mcp-server/v1.2.3 (Editor \(Preview\) \\client/v\(1\)\\build)`, + }, + { + name: "modern control characters", version: "v1.2.3", + clientInfo: &mcp.Implementation{Name: "editor(\x00)\\client\r\nX-Fake:1", Version: "1.0\tbeta\x7f"}, + want: `github-mcp-server/v1.2.3 (editor\(\x00\)\\client\r\nX-Fake:1/1.0\tbeta\x7f)`, + }, + { + name: "SDK control characters", handshake: "sdk", version: "v1.2.3", insiders: true, + clientInfo: &mcp.Implementation{Name: "editor(\x00)\\client\r\nX-Fake:1", Version: "1.0\tbeta\x7f"}, + want: `github-mcp-server/v1.2.3 (editor\(\x00\)\\client\r\nX-Fake:1/1.0\tbeta\x7f) (insiders)`, + }, + { + name: "legacy control characters", handshake: "legacy", version: "v1.2.3", + clientInfo: &mcp.Implementation{Name: "editor(\x00)\\client\r\nX-Fake:1", Version: "1.0\tbeta\x7f"}, + want: `github-mcp-server/v1.2.3 (editor\(\x00\)\\client\r\nX-Fake:1/1.0\tbeta\x7f)`, + }, + { + name: "spaces and Unicode preserved", version: "v1.2.3", + clientInfo: &mcp.Implementation{Name: "VS Code \u03b2", Version: "\u03b1 1.0"}, + want: "github-mcp-server/v1.2.3 (VS Code \u03b2/\u03b1 1.0)", + }, + { + name: "empty client metadata", version: "v1.2.3", + clientInfo: &mcp.Implementation{}, + want: "github-mcp-server/v1.2.3 (/)", + }, + { + name: "quoted client metadata", version: "v1.2.3", + clientInfo: &mcp.Implementation{Name: `"Editor"`, Version: `v"1`}, + want: `github-mcp-server/v1.2.3 (\"Editor\"/v\"1)`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + headers := make(chan http.Header, 2) + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers <- r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"repository":{"isPrivate":false,"issues":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}`)) + })) + defer api.Close() + + cfg := github.MCPServerConfig{ + Version: tt.version, Token: "fixture-token", InsidersMode: tt.insiders, + Translator: translations.NullTranslationHelper, Logger: discardLogger(), + } + clients, err := createGitHubClients(cfg, newStaticAPIHostResolver(t, api.URL)) + require.NoError(t, err) + obs, err := observability.NewExporters(cfg.Logger, metrics.NewNoopMetrics()) + require.NoError(t, err) + deps := github.NewBaseDeps( + clients.rest, clients.gql, clients.raw, nil, cfg.Translator, + github.FeatureFlags{}, 5000, createFeatureChecker(nil, tt.insiders), obs, + ) + inv, err := github.NewInventory(cfg.Translator). + WithToolsets([]string{}).WithTools([]string{"list_issues"}).Build() + require.NoError(t, err) + server, err := github.NewMCPServer(ctx, &cfg, deps, inv) + require.NoError(t, err) + server.AddReceivingMiddleware(addUserAgentsMiddleware(cfg)) + var initialized atomic.Bool + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if method == "initialize" { + initialized.Store(true) + } + return next(ctx, method, req) + } + }) + + serverConn, clientConn := net.Pipe() + defer clientConn.Close() + require.NoError(t, clientConn.SetDeadline(time.Now().Add(10*time.Second))) + session, err := server.Connect(ctx, &mcp.IOTransport{Reader: serverConn, Writer: serverConn}, nil) + require.NoError(t, err) + defer session.Close() + params := &mcp.CallToolParams{ + Name: "list_issues", Arguments: map[string]any{"owner": "owner", "repo": "repo"}, + } + var changedClient bool + if tt.handshake == "sdk" { + client := mcp.NewClient(tt.clientInfo, nil) + cs, err := client.Connect(ctx, &mcp.IOTransport{Reader: clientConn, Writer: clientConn}, nil) + require.NoError(t, err) + defer cs.Close() + assert.Equal(t, "2026-07-28", cs.InitializeResult().ProtocolVersion) + result, err := cs.CallTool(ctx, params) + require.NoError(t, err) + require.False(t, result.IsError, "%+v", result.Content) + } else { + encoder, decoder := json.NewEncoder(clientConn), json.NewDecoder(clientConn) + id := 0 + call := func(method string, params any) json.RawMessage { + t.Helper() + id++ + require.NoError(t, encoder.Encode(map[string]any{ + "jsonrpc": "2.0", "id": id, "method": method, "params": params, + })) + var response struct { + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + } + require.NoError(t, decoder.Decode(&response)) + require.Empty(t, response.Error) + return response.Result + } + if tt.handshake == "legacy" { + call("initialize", &mcp.InitializeParams{ + ProtocolVersion: "2025-11-25", Capabilities: &mcp.ClientCapabilities{}, + ClientInfo: tt.clientInfo, + }) + require.NoError(t, encoder.Encode(map[string]any{ + "jsonrpc": "2.0", "method": "notifications/initialized", + })) + } else { + params.Meta = mcp.Meta{ + mcp.MetaKeyProtocolVersion: "2026-07-28", mcp.MetaKeyClientCapabilities: map[string]any{}, + } + if tt.clientInfo != nil { + params.Meta[mcp.MetaKeyClientInfo] = tt.clientInfo + } + } + var result mcp.CallToolResult + require.NoError(t, json.Unmarshal(call("tools/call", params), &result)) + require.False(t, result.IsError, "%+v", result.Content) + if tt.handshake != "legacy" { + params.Meta[mcp.MetaKeyClientInfo] = &mcp.Implementation{Name: "other-client", Version: "7.8.9"} + require.NoError(t, json.Unmarshal(call("tools/call", params), &result)) + require.False(t, result.IsError, "%+v", result.Content) + changedClient = true + } + } + + assert.Equal(t, tt.handshake == "legacy", initialized.Load()) + select { + case header := <-headers: + assert.Equal(t, tt.want, header.Get("User-Agent")) + assert.Equal(t, "Bearer fixture-token", header.Get("Authorization")) + assert.Equal(t, "issue_fields, repo_issue_fields", header.Get("GraphQL-Features")) + case <-ctx.Done(): + t.Fatal("GraphQL request did not reach the local HTTP server") + } + if changedClient { + want := "github-mcp-server/" + tt.version + " (other-client/7.8.9)" + if tt.insiders { + want += " (insiders)" + } + assert.Equal(t, want, (<-headers).Get("User-Agent")) + } + }) + } +} diff --git a/internal/oauth/testutil_test.go b/internal/oauth/testutil_test.go index dc34c3841c..5c0ac501f7 100644 --- a/internal/oauth/testutil_test.go +++ b/internal/oauth/testutil_test.go @@ -73,7 +73,7 @@ func (f *fakeGitHub) handleAuthorize(w http.ResponseWriter, r *http.Request) { f.mu.Unlock() redirect := q.Get("redirect_uri") + "?code=authcode&state=" + url.QueryEscape(q.Get("state")) - http.Redirect(w, r, redirect, http.StatusFound) + http.Redirect(w, r, redirect, http.StatusFound) //nolint:gosec // G710: Fake OAuth server redirects to the test client's callback, not an external user's input. } func (f *fakeGitHub) handleToken(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/buffer/buffer_test.go b/pkg/buffer/buffer_test.go index 86308ec5e3..b66c3bc20b 100644 --- a/pkg/buffer/buffer_test.go +++ b/pkg/buffer/buffer_test.go @@ -83,14 +83,14 @@ func TestProcessResponseAsRingBufferToEnd(t *testing.T) { // 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)) + fmt.Fprintf(&sb, "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)) + fmt.Fprintf(&sb, "line%d\n", i) } resp := &http.Response{ diff --git a/pkg/context/mcp_info.go b/pkg/context/mcp_info.go index af474b13a0..f589bafaed 100644 --- a/pkg/context/mcp_info.go +++ b/pkg/context/mcp_info.go @@ -3,6 +3,8 @@ package context import ( "context" "encoding/json" + + "github.com/modelcontextprotocol/go-sdk/mcp" ) type mcpMethodInfoCtx string @@ -22,6 +24,16 @@ type MCPMethodInfo struct { ItemName string // RawArguments contains the unmaterialized tool arguments for tools/call requests. RawArguments json.RawMessage + // NormalizedArguments contains canonical arguments prepared by HTTP scope + // middleware so tool adapters do not rerun non-idempotent normalizers. + NormalizedArguments json.RawMessage + // ArgumentsNormalized distinguishes an intentionally empty normalized value + // from a request that has not been normalized. + ArgumentsNormalized bool + // ProtocolVersion and ClientCapabilities describe the requesting MCP client + // when stateless HTTP parsing makes them available before registration. + ProtocolVersion string + ClientCapabilities *mcp.ClientCapabilities } // DecodeArguments materializes tool arguments when request middleware needs diff --git a/pkg/context/request.go b/pkg/context/request.go index 6d8d8a1060..548dc7f575 100644 --- a/pkg/context/request.go +++ b/pkg/context/request.go @@ -98,15 +98,15 @@ func GetExcludeTools(ctx context.Context) []string { return nil } -// headerFeaturesCtxKey is a context key for raw header feature flags +// headerFeaturesCtxKey is a context key for raw HTTP request feature flags. type headerFeaturesCtxKey struct{} -// WithHeaderFeatures stores the raw feature flags from the X-MCP-Features header into context +// WithHeaderFeatures stores raw HTTP request feature flags in context. func WithHeaderFeatures(ctx context.Context, features []string) context.Context { return context.WithValue(ctx, headerFeaturesCtxKey{}, features) } -// GetHeaderFeatures retrieves the raw feature flags from context +// GetHeaderFeatures retrieves raw HTTP request feature flags from context. func GetHeaderFeatures(ctx context.Context) []string { if features, ok := ctx.Value(headerFeaturesCtxKey{}).([]string); ok { return features diff --git a/pkg/context/token.go b/pkg/context/token.go index 97091a922f..32f52b64a5 100644 --- a/pkg/context/token.go +++ b/pkg/context/token.go @@ -3,7 +3,7 @@ package context import ( "context" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/utils" ) type tokenCtxKey struct{} diff --git a/pkg/errors/error.go b/pkg/errors/error.go index 13b607b405..7c9fb049e1 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -9,9 +9,9 @@ import ( "strings" "time" - "github.com/github/github-mcp-server/pkg/sanitize" - "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/pkg/sanitize" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -165,8 +165,7 @@ func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github _, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling } - var rateLimitErr *github.RateLimitError - if stderrors.As(err, &rateLimitErr) { + if rateLimitErr, ok := stderrors.AsType[*github.RateLimitError](err); ok { resetTime := rateLimitErr.Rate.Reset.Time if !resetTime.IsZero() { retryIn := time.Until(resetTime).Round(time.Second) @@ -179,8 +178,7 @@ func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github "%s: GitHub API rate limit exceeded. Wait before retrying.", message)) } - var abuseErr *github.AbuseRateLimitError - if stderrors.As(err, &abuseErr) { + if abuseErr, ok := stderrors.AsType[*github.AbuseRateLimitError](err); ok { if abuseErr.RetryAfter != nil { retryAfter := abuseErr.RetryAfter.Round(time.Second) if retryAfter > 0 { @@ -256,8 +254,7 @@ func formatGitHubValidationDetail(validationErr github.Error) string { } func sanitizeGitHubValidationText(value string) string { - // Tool errors are plain text; keep quoted branch patterns readable. - sanitized := strings.ReplaceAll(sanitize.Sanitize(value), "'", "'") + sanitized := sanitize.PlainText(value) return strings.Join(strings.Fields(sanitized), " ") } diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go index 9938c12df9..dc47b9d58b 100644 --- a/pkg/errors/error_test.go +++ b/pkg/errors/error_test.go @@ -3,7 +3,7 @@ package errors import ( "context" "fmt" - "github.com/google/go-github/v89/github" + "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -703,13 +703,13 @@ func TestNewGitHubAPIErrorResponse_ValidationMessages(t *testing.T) { originalErr := &github.ErrorResponse{ Response: response, - Message: "Validation Failed\u202e", + Message: "Validation Failed\u202e for AT&T", Errors: []github.Error{ { Resource: "GitRef", Field: "ref", Code: "custom", - Message: "ref name does not match the required pattern 'feature/*'\u202e", + Message: `ref name does not match the required pattern 'feature/*' or "release/*"` + "\u202e", }, }, DocumentationURL: "https://docs.github.test/private?token=secret-doc-token", @@ -724,7 +724,8 @@ func TestNewGitHubAPIErrorResponse_ValidationMessages(t *testing.T) { ) text := requireErrorText(t, result) - assert.Equal(t, "failed to create branch: Validation Failed\nGitRef.ref (custom): ref name does not match the required pattern 'feature/*'", text) + assert.Equal(t, `failed to create branch: Validation Failed for AT&T +GitRef.ref (custom): ref name does not match the required pattern 'feature/*' or "release/*"`, text) assert.NotContains(t, text, "create ref") assert.NotContains(t, text, "https://") assert.NotContains(t, text, "secret-") diff --git a/pkg/github/__toolsnaps__/actions_get.snap b/pkg/github/__toolsnaps__/actions_get.snap index 661f379f5f..8e87fadf6b 100644 --- a/pkg/github/__toolsnaps__/actions_get.snap +++ b/pkg/github/__toolsnaps__/actions_get.snap @@ -40,5 +40,926 @@ ], "type": "object" }, - "name": "actions_get" + "name": "actions_get", + "outputSchema": { + "additionalProperties": false, + "oneOf": [ + { + "properties": { + "artifact": { + "not": { + "description": "Any value." + } + }, + "logs": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_workflow" + ] + }, + "usage": { + "not": { + "description": "Any value." + } + }, + "workflow_job": { + "not": { + "description": "Any value." + } + }, + "workflow_run": { + "not": { + "description": "Any value." + } + } + } + }, + { + "properties": { + "artifact": { + "not": { + "description": "Any value." + } + }, + "logs": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_workflow_run" + ] + }, + "usage": { + "not": { + "description": "Any value." + } + }, + "workflow": { + "not": { + "description": "Any value." + } + }, + "workflow_job": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "workflow_run" + ] + }, + { + "properties": { + "artifact": { + "not": { + "description": "Any value." + } + }, + "logs": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_workflow_job" + ] + }, + "usage": { + "not": { + "description": "Any value." + } + }, + "workflow": { + "not": { + "description": "Any value." + } + }, + "workflow_run": { + "not": { + "description": "Any value." + } + } + } + }, + { + "properties": { + "logs": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "download_workflow_run_artifact" + ] + }, + "usage": { + "not": { + "description": "Any value." + } + }, + "workflow": { + "not": { + "description": "Any value." + } + }, + "workflow_job": { + "not": { + "description": "Any value." + } + }, + "workflow_run": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "artifact" + ] + }, + { + "properties": { + "artifact": { + "not": { + "description": "Any value." + } + }, + "logs": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_workflow_run_usage" + ] + }, + "workflow": { + "not": { + "description": "Any value." + } + }, + "workflow_job": { + "not": { + "description": "Any value." + } + }, + "workflow_run": { + "not": { + "description": "Any value." + } + } + } + }, + { + "properties": { + "artifact": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_workflow_run_logs_url" + ] + }, + "usage": { + "not": { + "description": "Any value." + } + }, + "workflow": { + "not": { + "description": "Any value." + } + }, + "workflow_job": { + "not": { + "description": "Any value." + } + }, + "workflow_run": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "logs" + ] + } + ], + "properties": { + "artifact": { + "additionalProperties": false, + "properties": { + "artifact_id": { + "type": "integer" + }, + "download_url": { + "type": "string" + }, + "message": { + "type": "string" + }, + "note": { + "type": "string" + } + }, + "required": [ + "artifact_id", + "download_url", + "message", + "note" + ], + "type": [ + "null", + "object" + ] + }, + "logs": { + "additionalProperties": false, + "properties": { + "logs_url": { + "type": "string" + }, + "message": { + "type": "string" + }, + "note": { + "type": "string" + }, + "optimization_tip": { + "type": "string" + }, + "warning": { + "type": "string" + } + }, + "required": [ + "logs_url", + "message", + "note", + "optimization_tip", + "warning" + ], + "type": [ + "null", + "object" + ] + }, + "method": { + "description": "Operation that produced this response.", + "enum": [ + "get_workflow", + "get_workflow_run", + "get_workflow_job", + "download_workflow_run_artifact", + "get_workflow_run_usage", + "get_workflow_run_logs_url" + ], + "type": "string" + }, + "usage": { + "additionalProperties": false, + "properties": { + "billable": { + "items": { + "additionalProperties": false, + "properties": { + "job_runs": { + "items": { + "additionalProperties": false, + "properties": { + "duration_ms": { + "description": "Duration in milliseconds.", + "type": [ + "null", + "integer" + ] + }, + "job_id": { + "type": [ + "null", + "integer" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "jobs": { + "type": [ + "null", + "integer" + ] + }, + "runner": { + "description": "Runner environment name; custom environments are supported.", + "type": "string" + }, + "total_ms": { + "description": "Duration in milliseconds.", + "type": [ + "null", + "integer" + ] + } + }, + "required": [ + "runner" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "run_duration_ms": { + "description": "Duration in milliseconds.", + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "workflow": { + "additionalProperties": false, + "properties": { + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "state": { + "description": "Workflow state, e.g. active, deleted, disabled_fork, disabled_inactivity, disabled_manually.", + "enum": [ + "active", + "deleted", + "disabled_fork", + "disabled_inactivity", + "disabled_manually" + ], + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "workflow_job": { + "additionalProperties": false, + "properties": { + "completed_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "head_branch": { + "type": "string" + }, + "head_sha": { + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "name": { + "type": "string" + }, + "run_attempt": { + "type": "integer" + }, + "run_id": { + "type": "integer" + }, + "runner_group_id": { + "type": "integer" + }, + "runner_group_name": { + "type": "string" + }, + "runner_id": { + "type": "integer" + }, + "runner_name": { + "type": "string" + }, + "started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + }, + "steps": { + "items": { + "additionalProperties": false, + "properties": { + "completed_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + } + }, + "required": [ + "name", + "status", + "number" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "workflow_name": { + "type": "string" + } + }, + "required": [ + "id", + "run_id", + "name", + "status" + ], + "type": [ + "null", + "object" + ] + }, + "workflow_run": { + "additionalProperties": false, + "properties": { + "actor": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "display_title": { + "type": "string" + }, + "event": { + "type": "string" + }, + "head_branch": { + "type": "string" + }, + "head_commit": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + }, + "head_sha": { + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "pull_requests": { + "items": { + "type": "integer" + }, + "type": [ + "null", + "array" + ] + }, + "referenced_workflows": { + "items": { + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "sha": { + "type": "string" + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "run_attempt": { + "type": "integer" + }, + "run_number": { + "type": "integer" + }, + "run_started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + }, + "triggering_actor": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "workflow_id": { + "type": "integer" + } + }, + "required": [ + "id", + "name", + "workflow_id", + "run_number", + "run_attempt", + "status" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "method" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/actions_list.snap b/pkg/github/__toolsnaps__/actions_list.snap index be97affbdb..28b7a789fc 100644 --- a/pkg/github/__toolsnaps__/actions_list.snap +++ b/pkg/github/__toolsnaps__/actions_list.snap @@ -26,7 +26,7 @@ "minimum": 1, "type": "number" }, - "per_page": { + "perPage": { "description": "Results per page for pagination (default: 30, max: 100)", "maximum": 100, "minimum": 1, @@ -125,5 +125,844 @@ ], "type": "object" }, - "name": "actions_list" + "name": "actions_list", + "outputSchema": { + "additionalProperties": false, + "oneOf": [ + { + "properties": { + "artifacts": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "list_workflows" + ] + }, + "workflow_jobs": { + "not": { + "description": "Any value." + } + }, + "workflow_runs": { + "not": { + "description": "Any value." + } + } + } + }, + { + "properties": { + "artifacts": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "list_workflow_runs" + ] + }, + "workflow_jobs": { + "not": { + "description": "Any value." + } + }, + "workflows": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "workflow_runs" + ] + }, + { + "properties": { + "artifacts": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "list_workflow_jobs" + ] + }, + "workflow_runs": { + "not": { + "description": "Any value." + } + }, + "workflows": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "workflow_jobs" + ] + }, + { + "properties": { + "method": { + "enum": [ + "list_workflow_run_artifacts" + ] + }, + "workflow_jobs": { + "not": { + "description": "Any value." + } + }, + "workflow_runs": { + "not": { + "description": "Any value." + } + }, + "workflows": { + "not": { + "description": "Any value." + } + } + } + } + ], + "properties": { + "artifacts": { + "additionalProperties": false, + "properties": { + "artifacts": { + "items": { + "additionalProperties": false, + "properties": { + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "digest": { + "type": [ + "null", + "string" + ] + }, + "expired": { + "type": [ + "null", + "boolean" + ] + }, + "expires_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "size_in_bytes": { + "description": "Artifact size in bytes.", + "type": [ + "null", + "integer" + ] + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "workflow_run": { + "additionalProperties": false, + "properties": { + "head_branch": { + "type": [ + "null", + "string" + ] + }, + "head_sha": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "method": { + "description": "Operation that produced this response.", + "enum": [ + "list_workflows", + "list_workflow_runs", + "list_workflow_jobs", + "list_workflow_run_artifacts" + ], + "type": "string" + }, + "workflow_jobs": { + "additionalProperties": false, + "properties": { + "jobs": { + "items": { + "additionalProperties": false, + "properties": { + "completed_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "head_branch": { + "type": "string" + }, + "head_sha": { + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "name": { + "type": "string" + }, + "run_attempt": { + "type": "integer" + }, + "run_id": { + "type": "integer" + }, + "runner_group_id": { + "type": "integer" + }, + "runner_group_name": { + "type": "string" + }, + "runner_id": { + "type": "integer" + }, + "runner_name": { + "type": "string" + }, + "started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + }, + "steps": { + "items": { + "additionalProperties": false, + "properties": { + "completed_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + } + }, + "required": [ + "name", + "status", + "number" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "workflow_name": { + "type": "string" + } + }, + "required": [ + "id", + "run_id", + "name", + "status" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "jobs" + ], + "type": [ + "null", + "object" + ] + }, + "workflow_runs": { + "additionalProperties": false, + "properties": { + "total_count": { + "type": "integer" + }, + "workflow_runs": { + "items": { + "additionalProperties": false, + "properties": { + "actor": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "display_title": { + "type": "string" + }, + "event": { + "type": "string" + }, + "head_branch": { + "type": "string" + }, + "head_commit": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + }, + "head_sha": { + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "pull_requests": { + "items": { + "type": "integer" + }, + "type": [ + "null", + "array" + ] + }, + "referenced_workflows": { + "items": { + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "sha": { + "type": "string" + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "run_attempt": { + "type": "integer" + }, + "run_number": { + "type": "integer" + }, + "run_started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + }, + "triggering_actor": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "workflow_id": { + "type": "integer" + } + }, + "required": [ + "id", + "name", + "workflow_id", + "run_number", + "run_attempt", + "status" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "total_count", + "workflow_runs" + ], + "type": [ + "null", + "object" + ] + }, + "workflows": { + "additionalProperties": false, + "properties": { + "total_count": { + "type": [ + "null", + "integer" + ] + }, + "workflows": { + "items": { + "additionalProperties": false, + "properties": { + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "state": { + "description": "Workflow state, e.g. active, deleted, disabled_fork, disabled_inactivity, disabled_manually.", + "enum": [ + "active", + "deleted", + "disabled_fork", + "disabled_inactivity", + "disabled_manually" + ], + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } + }, + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "method" + ], + "type": "object" + } } \ 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 0c859b86ec..21387727ee 100644 --- a/pkg/github/__toolsnaps__/actions_run_trigger.snap +++ b/pkg/github/__toolsnaps__/actions_run_trigger.snap @@ -52,5 +52,274 @@ ], "type": "object" }, - "name": "actions_run_trigger" + "name": "actions_run_trigger", + "outputSchema": { + "$defs": { + "run_operation": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + }, + "run_id": { + "type": "integer" + }, + "status": { + "description": "HTTP response status.", + "type": "string" + }, + "status_code": { + "type": "integer" + } + }, + "required": [ + "message", + "run_id", + "status", + "status_code" + ], + "type": "object" + } + }, + "additionalProperties": false, + "oneOf": [ + { + "properties": { + "cancel": { + "not": { + "description": "Any value." + } + }, + "delete_logs": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "run_workflow" + ] + }, + "rerun": { + "not": { + "description": "Any value." + } + }, + "rerun_failed": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "dispatch" + ] + }, + { + "properties": { + "cancel": { + "not": { + "description": "Any value." + } + }, + "delete_logs": { + "not": { + "description": "Any value." + } + }, + "dispatch": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "rerun_workflow_run" + ] + }, + "rerun_failed": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "rerun" + ] + }, + { + "properties": { + "cancel": { + "not": { + "description": "Any value." + } + }, + "delete_logs": { + "not": { + "description": "Any value." + } + }, + "dispatch": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "rerun_failed_jobs" + ] + }, + "rerun": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "rerun_failed" + ] + }, + { + "properties": { + "delete_logs": { + "not": { + "description": "Any value." + } + }, + "dispatch": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "cancel_workflow_run" + ] + }, + "rerun": { + "not": { + "description": "Any value." + } + }, + "rerun_failed": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "cancel" + ] + }, + { + "properties": { + "cancel": { + "not": { + "description": "Any value." + } + }, + "dispatch": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "delete_workflow_run_logs" + ] + }, + "rerun": { + "not": { + "description": "Any value." + } + }, + "rerun_failed": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "delete_logs" + ] + } + ], + "properties": { + "cancel": { + "$ref": "#/$defs/run_operation" + }, + "delete_logs": { + "$ref": "#/$defs/run_operation" + }, + "dispatch": { + "additionalProperties": false, + "properties": { + "inputs": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "message": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "status": { + "description": "HTTP response status.", + "type": "string" + }, + "status_code": { + "type": "integer" + }, + "workflow_id": { + "type": "string" + }, + "workflow_type": { + "enum": [ + "workflow_file", + "workflow_id" + ], + "type": "string" + } + }, + "required": [ + "inputs", + "message", + "ref", + "status", + "status_code", + "workflow_id", + "workflow_type" + ], + "type": [ + "null", + "object" + ] + }, + "method": { + "description": "Operation that produced this response.", + "enum": [ + "run_workflow", + "rerun_workflow_run", + "rerun_failed_jobs", + "cancel_workflow_run", + "delete_workflow_run_logs" + ], + "type": "string" + }, + "rerun": { + "$ref": "#/$defs/run_operation" + }, + "rerun_failed": { + "$ref": "#/$defs/run_operation" + } + }, + "required": [ + "method" + ], + "type": "object" + } } \ 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 4a6e6ca9ba..8296a90ccb 100644 --- a/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap +++ b/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap @@ -70,5 +70,18 @@ ], "type": "object" }, - "name": "add_comment_to_pending_review" + "name": "add_comment_to_pending_review", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Operation result message.", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } } \ 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 a68494d448..58102f70b1 100644 --- a/pkg/github/__toolsnaps__/add_issue_comment.snap +++ b/pkg/github/__toolsnaps__/add_issue_comment.snap @@ -51,5 +51,88 @@ ], "type": "object" }, - "name": "add_issue_comment" + "name": "add_issue_comment", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "The web URL of the created issue comment.", + "type": "string" + }, + "id": { + "description": "The ID of the created comment or reaction.", + "type": "string" + } + }, + "required": [ + "id", + "html_url" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "id": { + "description": "The ID of the created reaction.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "comment": { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "The web URL of the created issue comment.", + "type": "string" + }, + "id": { + "description": "The ID of the created comment or reaction.", + "type": "string" + } + }, + "required": [ + "id", + "html_url" + ], + "type": "object" + }, + "reaction": { + "additionalProperties": false, + "properties": { + "id": { + "description": "The ID of the created reaction.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + "required": [ + "comment", + "reaction" + ], + "type": "object" + } + ], + "type": [ + "object", + "null" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_issue_comment_reaction.snap b/pkg/github/__toolsnaps__/add_issue_comment_reaction.snap index fea8434a62..8cad3f22b4 100644 --- a/pkg/github/__toolsnaps__/add_issue_comment_reaction.snap +++ b/pkg/github/__toolsnaps__/add_issue_comment_reaction.snap @@ -7,6 +7,18 @@ "title": "Add Reaction to Issue or Pull Request Comment" }, "description": "Add a reaction to an issue or pull request comment.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "comment_id": { @@ -30,11 +42,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -45,5 +59,24 @@ ], "type": "object" }, - "name": "add_issue_comment_reaction" + "name": "add_issue_comment_reaction", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_issue_reaction.snap b/pkg/github/__toolsnaps__/add_issue_reaction.snap index 49175d1412..de42bb0765 100644 --- a/pkg/github/__toolsnaps__/add_issue_reaction.snap +++ b/pkg/github/__toolsnaps__/add_issue_reaction.snap @@ -7,6 +7,18 @@ "title": "Add Reaction to Issue or Pull Request" }, "description": "Add a reaction to an issue or pull request.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "content": { @@ -30,11 +42,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -45,5 +59,24 @@ ], "type": "object" }, - "name": "add_issue_reaction" + "name": "add_issue_reaction", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ 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 index ae7450539d..0ae86e5c1c 100644 --- a/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap +++ b/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap @@ -7,6 +7,18 @@ "title": "Add Pull Request Review Comment" }, "description": "Add a review comment to the current user's pending pull request review.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "body": { @@ -19,7 +31,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "path": { "description": "The relative path of the file to comment on", @@ -32,7 +45,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "side": { "description": "The side of the diff to comment on (optional)", @@ -73,5 +87,20 @@ ], "type": "object" }, - "name": "add_pull_request_review_comment" + "name": "add_pull_request_review_comment", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap b/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap index aec4b38465..73ea0f877c 100644 --- a/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap +++ b/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap @@ -7,6 +7,18 @@ "title": "Add Pull Request Review Comment Reaction" }, "description": "Add a reaction to a pull request review comment.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "comment_id": { @@ -30,11 +42,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -45,5 +59,23 @@ ], "type": "object" }, - "name": "add_pull_request_review_comment_reaction" + "name": "add_pull_request_review_comment_reaction", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "description": "Reaction ID", + "type": "string" + }, + "url": { + "description": "Reaction URL", + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": "object" + } } \ 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 index a86ea3d4b5..8640ba2cf3 100644 --- a/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap +++ b/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap @@ -50,5 +50,67 @@ ], "type": "object" }, - "name": "add_reply_to_pull_request_comment" + "name": "add_reply_to_pull_request_comment", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "comment": { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "reaction": { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + "required": [ + "comment", + "reaction" + ], + "type": "object" + } + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_sub_issue.snap b/pkg/github/__toolsnaps__/add_sub_issue.snap index a232507d4f..9f125b956e 100644 --- a/pkg/github/__toolsnaps__/add_sub_issue.snap +++ b/pkg/github/__toolsnaps__/add_sub_issue.snap @@ -7,6 +7,18 @@ "title": "Add Sub-Issue" }, "description": "Add a sub-issue to a parent issue.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "issue_number": { @@ -16,7 +28,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "replace_parent": { "description": "If true, reparent the sub-issue if it already has a parent", @@ -24,7 +37,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "sub_issue_id": { "description": "The ID of the sub-issue to add. ID is not the same as issue number", @@ -39,5 +53,217 @@ ], "type": "object" }, - "name": "add_sub_issue" + "name": "add_sub_issue", + "outputSchema": { + "additionalProperties": false, + "properties": { + "issue": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "closed_at": { + "description": "Closure time (RFC 3339), when closed.", + "type": "string" + }, + "comments": { + "description": "Number of comments.", + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "description": "Creation time (RFC 3339).", + "type": "string" + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "description": "REST issue field type.", + "enum": [ + "text", + "number", + "date", + "single_select" + ], + "type": "string" + }, + "issue_field_id": { + "type": "integer" + }, + "value": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + }, + "required": [ + "issue_field_id", + "data_type", + "value" + ], + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "html_url": { + "description": "Human-readable issue link.", + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "issue_type": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "milestone": { + "type": "string" + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "state": { + "description": "Issue state (lowercase REST value).", + "enum": [ + "open", + "closed" + ], + "type": [ + "null", + "string" + ] + }, + "state_reason": { + "enum": [ + "completed", + "not_planned", + "reopened", + "duplicate" + ], + "type": [ + "null", + "string" + ] + }, + "sub_issues_summary": { + "additionalProperties": false, + "properties": { + "completed": { + "description": "Number of completed sub-issues.", + "type": "integer" + }, + "percent_completed": { + "description": "Completed sub-issues as a percentage (0-100).", + "type": "integer" + }, + "total": { + "description": "Total number of sub-issues.", + "type": "integer" + } + }, + "required": [ + "total", + "completed", + "percent_completed" + ], + "type": [ + "null", + "object" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last update time (RFC 3339).", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "assignees" + ], + "type": [ + "object", + "null" + ] + }, + "method": { + "enum": [ + "add", + "remove", + "reprioritize" + ], + "type": "string" + } + }, + "required": [ + "method", + "issue" + ], + "type": "object" + } } \ 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 5f44b2c6c2..1e0f50413f 100644 --- a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap +++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap @@ -47,5 +47,70 @@ ], "type": "object" }, - "name": "assign_copilot_to_issue" + "name": "assign_copilot_to_issue", + "outputSchema": { + "additionalProperties": false, + "properties": { + "issue_number": { + "description": "The assigned issue's number.", + "type": "integer" + }, + "issue_url": { + "description": "Canonical browser URL of the assigned issue.", + "type": "string" + }, + "message": { + "description": "Assignment outcome or tool error message.", + "type": "string" + }, + "note": { + "description": "Additional status detail when Copilot has not created a pull request yet.", + "type": "string" + }, + "owner": { + "type": "string" + }, + "pull_request": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "state": { + "enum": [ + "OPEN", + "CLOSED", + "MERGED" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "description": "Canonical browser URL of the linked pull request.", + "type": "string" + } + }, + "required": [ + "number", + "state", + "title", + "url" + ], + "type": "object" + }, + "repo": { + "type": "string" + } + }, + "required": [ + "issue_number", + "issue_url", + "message", + "owner", + "repo" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap index 956c2a8142..4b51b74a91 100644 --- a/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap +++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap @@ -68,5 +68,74 @@ ], "type": "object" }, - "name": "assign_copilot_to_issue_with_intent" + "name": "assign_copilot_to_issue_with_intent", + "outputSchema": { + "additionalProperties": false, + "properties": { + "is_suggestion": { + "type": "boolean" + }, + "issue_number": { + "description": "The assigned issue's number.", + "type": "integer" + }, + "issue_url": { + "description": "Canonical browser URL of the assigned issue.", + "type": "string" + }, + "message": { + "description": "Assignment outcome or tool error message.", + "type": "string" + }, + "note": { + "description": "Additional status detail when Copilot has not created a pull request yet.", + "type": "string" + }, + "owner": { + "type": "string" + }, + "pull_request": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "state": { + "enum": [ + "OPEN", + "CLOSED", + "MERGED" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "description": "Canonical browser URL of the linked pull request.", + "type": "string" + } + }, + "required": [ + "number", + "state", + "title", + "url" + ], + "type": "object" + }, + "repo": { + "type": "string" + } + }, + "required": [ + "is_suggestion", + "issue_number", + "issue_url", + "message", + "owner", + "repo" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_branch.snap b/pkg/github/__toolsnaps__/create_branch.snap index e796405140..b0039d13d1 100644 --- a/pkg/github/__toolsnaps__/create_branch.snap +++ b/pkg/github/__toolsnaps__/create_branch.snap @@ -5,6 +5,18 @@ "title": "Create branch" }, "description": "Create a new branch in a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "branch": { @@ -17,11 +29,13 @@ }, "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -31,5 +45,39 @@ ], "type": "object" }, - "name": "create_branch" + "name": "create_branch", + "outputSchema": { + "additionalProperties": false, + "properties": { + "object": { + "additionalProperties": false, + "properties": { + "sha": { + "type": "string" + }, + "type": { + "description": "Git object kind: commit, tag, tree, or blob.", + "type": "string" + } + }, + "required": [ + "type", + "sha" + ], + "type": "object" + }, + "ref": { + "description": "Fully qualified reference, such as refs/heads/main.", + "type": "string" + } + }, + "required": [ + "ref", + "object" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_gist.snap b/pkg/github/__toolsnaps__/create_gist.snap index f91810bd00..ab94493f61 100644 --- a/pkg/github/__toolsnaps__/create_gist.snap +++ b/pkg/github/__toolsnaps__/create_gist.snap @@ -31,5 +31,21 @@ ], "type": "object" }, - "name": "create_gist" + "name": "create_gist", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_issue.snap b/pkg/github/__toolsnaps__/create_issue.snap index d7de241265..b616605c21 100644 --- a/pkg/github/__toolsnaps__/create_issue.snap +++ b/pkg/github/__toolsnaps__/create_issue.snap @@ -7,6 +7,18 @@ "title": "Create Issue" }, "description": "Create a new issue in a GitHub repository with a title and optional body.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "body": { @@ -15,7 +27,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "parent_issue_number": { "description": "Issue number of the parent issue. The new issue is created and attached to this parent in the same operation.", @@ -32,7 +45,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "title": { "description": "Issue title", @@ -46,5 +60,24 @@ ], "type": "object" }, - "name": "create_issue" + "name": "create_issue", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_or_update_file.snap b/pkg/github/__toolsnaps__/create_or_update_file.snap index 37bf3b46bf..a16da95ede 100644 --- a/pkg/github/__toolsnaps__/create_or_update_file.snap +++ b/pkg/github/__toolsnaps__/create_or_update_file.snap @@ -4,7 +4,19 @@ "readOnlyHint": false, "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 rev-parse \u003cbranch\u003e:\u003cpath to file\u003e\n\nSHA MUST be provided for existing file updates.\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\nTo obtain the current blob SHA before updating, call the get_file_contents tool with the same owner, repo, and path, and set its ref parameter to this tool's branch value. The first text result reports the blob SHA for the requested path.\n\nSHA MUST be provided for existing file updates.\n", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "allow_symlink_write": { @@ -26,7 +38,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "path": { "description": "Path where to create/update the file", @@ -34,10 +47,11 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "sha": { - "description": "The blob SHA of the file being replaced. Required if the file already exists.", + "description": "The blob SHA of the file being replaced. Required if the file already exists. Retrieve it with get_file_contents using the same owner, repo, and path, with ref set to this tool's branch value.", "type": "string" } }, @@ -51,5 +65,83 @@ ], "type": "object" }, - "name": "create_or_update_file" + "name": "create_or_update_file", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commit": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "html_url": { + "type": "string" + }, + "message": { + "type": "string" + }, + "sha": { + "type": "string" + } + }, + "required": [ + "sha" + ], + "type": [ + "null", + "object" + ] + }, + "content": { + "additionalProperties": false, + "properties": { + "html_url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "sha": { + "type": "string" + }, + "size": { + "type": "integer" + } + }, + "required": [ + "name", + "path", + "sha", + "html_url" + ], + "type": [ + "null", + "object" + ] + } + }, + "type": [ + "null", + "object" + ] + } } \ 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 acb74bcd7a..749065d788 100644 --- a/pkg/github/__toolsnaps__/create_pull_request.snap +++ b/pkg/github/__toolsnaps__/create_pull_request.snap @@ -65,5 +65,44 @@ ], "type": "object" }, - "name": "create_pull_request" + "name": "create_pull_request", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "reason": { + "type": "string" + }, + "status": { + "enum": [ + "awaiting_user_submission" + ], + "type": "string" + } + }, + "required": [ + "status", + "reason" + ], + "type": "object" + } + ] + } } \ 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 index f8bf3f12a8..c75c290a1d 100644 --- a/pkg/github/__toolsnaps__/create_pull_request_review.snap +++ b/pkg/github/__toolsnaps__/create_pull_request_review.snap @@ -7,6 +7,18 @@ "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.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "body": { @@ -28,7 +40,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "pullNumber": { "description": "The pull request number", @@ -37,7 +50,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -47,5 +61,20 @@ ], "type": "object" }, - "name": "create_pull_request_review" + "name": "create_pull_request_review", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_repository.snap b/pkg/github/__toolsnaps__/create_repository.snap index d13cc69aa1..ca35ee406f 100644 --- a/pkg/github/__toolsnaps__/create_repository.snap +++ b/pkg/github/__toolsnaps__/create_repository.snap @@ -5,6 +5,18 @@ "title": "Create repository" }, "description": "Create a new GitHub repository in your account or specified organization", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "autoInit": { @@ -34,5 +46,24 @@ ], "type": "object" }, - "name": "create_repository" + "name": "create_repository", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_repository_ruleset.snap b/pkg/github/__toolsnaps__/create_repository_ruleset.snap new file mode 100644 index 0000000000..2172a0b9ae --- /dev/null +++ b/pkg/github/__toolsnaps__/create_repository_ruleset.snap @@ -0,0 +1,405 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": false, + "title": "Create repository ruleset" + }, + "description": "Create a new ruleset at the repository, organization, or enterprise level", + "inputSchema": { + "additionalProperties": false, + "properties": { + "bypass_actors": { + "description": "The actors that can bypass the rules in this ruleset", + "items": { + "additionalProperties": false, + "properties": { + "actor_id": { + "description": "The ID of the actor that can bypass a ruleset", + "type": "number" + }, + "actor_type": { + "description": "The type of actor that can bypass a ruleset. 'EnterpriseOwner' and 'EnterpriseRole' are only valid for 'enterprise' level rulesets.", + "enum": [ + "Integration", + "OrganizationAdmin", + "RepositoryRole", + "Team", + "DeployKey", + "User", + "EnterpriseOwner", + "EnterpriseRole" + ], + "type": "string" + }, + "bypass_mode": { + "description": "When the specified actor can bypass the ruleset. 'pull_request' only applies to branch rulesets and is not valid for the 'DeployKey' actor type. 'exempt' means rules are not run for that actor and no bypass audit entry is created.", + "enum": [ + "always", + "pull_request", + "exempt" + ], + "type": "string" + } + }, + "required": [ + "actor_type", + "bypass_mode" + ], + "type": "object" + }, + "type": "array" + }, + "conditions": { + "description": "Conditions for when this ruleset applies, e.g. {\"ref_name\": {\"include\": [\"refs/heads/main\"], \"exclude\": []}}", + "type": "object" + }, + "enforcement": { + "description": "The enforcement level of the ruleset. 'evaluate' allows admins to test rules before enforcing them", + "enum": [ + "disabled", + "active", + "evaluate" + ], + "type": "string" + }, + "enterprise": { + "description": "Enterprise slug. Required when level is 'enterprise'.", + "type": "string" + }, + "level": { + "description": "The level at which the ruleset is configured:\n- 'repository': A ruleset on a single repository (requires 'owner' and 'repo').\n- 'organization': A ruleset covering repositories in an organization (requires 'org').\n- 'enterprise': A ruleset covering repositories across an enterprise (requires 'enterprise').", + "enum": [ + "repository", + "organization", + "enterprise" + ], + "type": "string" + }, + "name": { + "description": "The name of the ruleset", + "type": "string" + }, + "org": { + "description": "Organization name. Required when level is 'organization'.", + "type": "string" + }, + "owner": { + "description": "Repository owner. Required when level is 'repository'.", + "type": "string" + }, + "repo": { + "description": "Repository name. Required when level is 'repository'.", + "type": "string" + }, + "rules": { + "description": "An array of rules within the ruleset. Each rule is an object with a 'type' (e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks') and, for rules that need configuration, a 'parameters' object", + "items": { + "additionalProperties": false, + "properties": { + "parameters": { + "description": "Parameters for rule types that require additional configuration", + "type": "object" + }, + "type": { + "description": "The type of rule, e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks'", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "type": "array" + }, + "target": { + "description": "The target of the ruleset. Defaults to 'branch'. 'repository' is only valid for 'organization' and 'enterprise' level rulesets.", + "enum": [ + "branch", + "tag", + "push", + "repository" + ], + "type": "string" + } + }, + "required": [ + "level", + "name", + "enforcement", + "rules" + ], + "type": "object" + }, + "name": "create_repository_ruleset", + "outputSchema": { + "$defs": { + "idCondition": { + "additionalProperties": false, + "properties": { + "organization_ids": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "repository_ids": { + "items": { + "type": "integer" + }, + "type": "array" + } + }, + "type": "object" + }, + "nameCondition": { + "additionalProperties": false, + "properties": { + "exclude": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "include": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "protected": { + "type": "boolean" + } + }, + "required": [ + "include", + "exclude" + ], + "type": "object" + }, + "propertyCondition": { + "additionalProperties": false, + "properties": { + "exclude": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "property_values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "source": { + "type": "string" + } + }, + "required": [ + "name", + "property_values" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "include": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "property_values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "source": { + "type": "string" + } + }, + "required": [ + "name", + "property_values" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "include", + "exclude" + ], + "type": "object" + }, + "rule": { + "additionalProperties": false, + "properties": { + "parameters": { + "description": "User-configured parameters for this rule type.", + "type": "object" + }, + "ruleset_id": { + "type": "integer" + }, + "ruleset_source": { + "type": "string" + }, + "ruleset_source_type": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "bypass_actors": { + "items": { + "additionalProperties": false, + "properties": { + "actor_id": { + "type": "integer" + }, + "actor_type": { + "type": "string" + }, + "bypass_mode": { + "type": "string" + } + }, + "required": [ + "actor_type", + "bypass_mode" + ], + "type": "object" + }, + "type": "array" + }, + "conditions": { + "additionalProperties": false, + "properties": { + "organization_id": { + "$ref": "#/$defs/idCondition", + "type": "object" + }, + "organization_name": { + "$ref": "#/$defs/nameCondition", + "type": "object" + }, + "organization_property": { + "$ref": "#/$defs/propertyCondition", + "type": "object" + }, + "ref_name": { + "$ref": "#/$defs/nameCondition", + "type": "object" + }, + "repository_id": { + "$ref": "#/$defs/idCondition", + "type": "object" + }, + "repository_name": { + "$ref": "#/$defs/nameCondition", + "type": "object" + }, + "repository_property": { + "$ref": "#/$defs/propertyCondition", + "type": "object" + } + }, + "type": "object" + }, + "created_at": { + "description": "RFC3339 creation time.", + "format": "date-time", + "type": "string" + }, + "current_user_can_bypass": { + "type": "string" + }, + "enforcement": { + "enum": [ + "", + "disabled", + "active", + "evaluate" + ], + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "rules": { + "items": { + "$ref": "#/$defs/rule", + "type": "object" + }, + "type": "array" + }, + "source": { + "type": "string" + }, + "source_type": { + "enum": [ + "Repository", + "Organization", + "Enterprise" + ], + "type": "string" + }, + "target": { + "enum": [ + "branch", + "tag", + "push", + "repository" + ], + "type": "string" + }, + "updated_at": { + "description": "RFC3339 last update time.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "name", + "source", + "enforcement" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/custom_properties_read.snap b/pkg/github/__toolsnaps__/custom_properties_read.snap new file mode 100644 index 0000000000..505245ecd6 --- /dev/null +++ b/pkg/github/__toolsnaps__/custom_properties_read.snap @@ -0,0 +1,165 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Read custom properties" + }, + "description": "Read custom properties at the repository, organization, or enterprise level. At the repository level this returns the property values assigned to a repository; at the organization and enterprise levels it returns the property definitions (schema). Select the level with the 'level' parameter.", + "inputSchema": { + "properties": { + "enterprise": { + "description": "Enterprise slug. Required when level is 'enterprise'.", + "type": "string" + }, + "level": { + "description": "The level at which custom properties are managed:\n- 'repository': The custom property VALUES assigned to a repository (requires 'owner' and 'repo').\n- 'organization': The custom property DEFINITIONS (schema) for an organization (requires 'org').\n- 'enterprise': The custom property DEFINITIONS (schema) for an enterprise (requires 'enterprise').", + "enum": [ + "repository", + "organization", + "enterprise" + ], + "type": "string" + }, + "org": { + "description": "Organization name. Required when level is 'organization'.", + "type": "string" + }, + "owner": { + "description": "Repository owner. Required when level is 'repository'.", + "type": "string" + }, + "repo": { + "description": "Repository name. Required when level is 'repository'.", + "type": "string" + } + }, + "required": [ + "level" + ], + "type": "object" + }, + "name": "custom_properties_read", + "outputSchema": { + "additionalProperties": false, + "properties": { + "definitions": { + "items": { + "additionalProperties": false, + "properties": { + "allowed_values": { + "items": { + "type": "string" + }, + "type": "array" + }, + "default_value": { + "description": "Repository level only: the value to assign. A string, an array of strings, or null to clear the value.", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "property_name": { + "type": "string" + }, + "require_explicit_values": { + "type": "boolean" + }, + "required": { + "type": "boolean" + }, + "value_type": { + "enum": [ + "", + "string", + "single_select", + "multi_select", + "true_false", + "url" + ], + "type": "string" + }, + "values_editable_by": { + "enum": [ + "org_actors", + "org_and_repo_actors" + ], + "type": "string" + } + }, + "required": [ + "property_name", + "value_type" + ], + "type": "object" + }, + "type": "array" + }, + "level": { + "enum": [ + "repository", + "organization", + "enterprise" + ], + "type": "string" + }, + "method": { + "enum": [ + "read" + ], + "type": "string" + }, + "values": { + "items": { + "additionalProperties": false, + "properties": { + "property_name": { + "type": "string" + }, + "value": { + "description": "Repository level only: the value to assign. A string, an array of strings, or null to clear the value.", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "property_name", + "value" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "method", + "level" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/custom_properties_write.snap b/pkg/github/__toolsnaps__/custom_properties_write.snap new file mode 100644 index 0000000000..ee751606c2 --- /dev/null +++ b/pkg/github/__toolsnaps__/custom_properties_write.snap @@ -0,0 +1,267 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": false, + "title": "Set custom properties" + }, + "description": "Create or update custom properties at the repository, organization, or enterprise level. At the repository level this sets the property values on a repository (the properties must already be defined for the organization). Organization and enterprise definition writes preserve omitted writable fields by reading current definitions immediately before updating; concurrent definition updates remain last-write-wins. Select the level with the 'level' parameter.", + "inputSchema": { + "properties": { + "enterprise": { + "description": "Enterprise slug. Required when level is 'enterprise'.", + "type": "string" + }, + "level": { + "description": "The level at which custom properties are managed:\n- 'repository': The custom property VALUES assigned to a repository (requires 'owner' and 'repo').\n- 'organization': The custom property DEFINITIONS (schema) for an organization (requires 'org').\n- 'enterprise': The custom property DEFINITIONS (schema) for an enterprise (requires 'enterprise').", + "enum": [ + "repository", + "organization", + "enterprise" + ], + "type": "string" + }, + "org": { + "description": "Organization name. Required when level is 'organization'.", + "type": "string" + }, + "owner": { + "description": "Repository owner. Required when level is 'repository'.", + "type": "string" + }, + "properties": { + "description": "The custom properties to create or update. At the repository level each item assigns a value ('property_name' and 'value'); at the organization and enterprise levels each item defines the schema ('property_name' and 'value_type', plus optional definition fields).", + "items": { + "oneOf": [ + { + "additionalProperties": false, + "description": "A repository-level custom property value.", + "properties": { + "property_name": { + "description": "The name of the custom property.", + "type": "string" + }, + "value": { + "description": "Repository level only: the value to assign. A string, an array of strings, or null to clear the value.", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "property_name", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "An organization- or enterprise-level custom property definition.", + "properties": { + "allowed_values": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "The ordered list of allowed values for single_select and multi_select properties. Omit when updating to preserve the current list; use null or an empty array to clear it." + }, + "default_value": { + "description": "The value applied when a repository does not set the property. Omit when updating to preserve the current value; use null to clear it.", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A short description of the property. Omit when updating to preserve the current description; use null to clear it." + }, + "property_name": { + "description": "The name of the custom property.", + "type": "string" + }, + "require_explicit_values": { + "description": "Whether repositories must explicitly set a value for the property. Omit when updating to preserve the current setting.", + "type": "boolean" + }, + "required": { + "description": "Whether the property must be set on every repository. Omit when updating to preserve the current setting.", + "type": "boolean" + }, + "value_type": { + "description": "The data type of the property. Required for new definitions; omit when updating to preserve the current type.", + "enum": [ + "string", + "single_select", + "multi_select", + "true_false", + "url" + ], + "type": "string" + }, + "values_editable_by": { + "anyOf": [ + { + "enum": [ + "org_actors", + "org_and_repo_actors" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Who can edit the values of the property. Omit when updating to preserve the current setting; use null to restore the default." + } + }, + "required": [ + "property_name" + ], + "type": "object" + } + ], + "type": "object" + }, + "type": "array" + }, + "repo": { + "description": "Repository name. Required when level is 'repository'.", + "type": "string" + } + }, + "required": [ + "level", + "properties" + ], + "type": "object" + }, + "name": "custom_properties_write", + "outputSchema": { + "additionalProperties": false, + "properties": { + "definitions": { + "items": { + "additionalProperties": false, + "properties": { + "allowed_values": { + "items": { + "type": "string" + }, + "type": "array" + }, + "default_value": { + "description": "Repository level only: the value to assign. A string, an array of strings, or null to clear the value.", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "property_name": { + "type": "string" + }, + "require_explicit_values": { + "type": "boolean" + }, + "required": { + "type": "boolean" + }, + "value_type": { + "enum": [ + "", + "string", + "single_select", + "multi_select", + "true_false", + "url" + ], + "type": "string" + }, + "values_editable_by": { + "enum": [ + "org_actors", + "org_and_repo_actors" + ], + "type": "string" + } + }, + "required": [ + "property_name", + "value_type" + ], + "type": "object" + }, + "type": "array" + }, + "level": { + "enum": [ + "repository", + "organization", + "enterprise" + ], + "type": "string" + }, + "message": { + "type": "string" + }, + "method": { + "enum": [ + "write" + ], + "type": "string" + } + }, + "required": [ + "method", + "level" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_file.snap b/pkg/github/__toolsnaps__/delete_file.snap index 4885c891d1..6240de7f04 100644 --- a/pkg/github/__toolsnaps__/delete_file.snap +++ b/pkg/github/__toolsnaps__/delete_file.snap @@ -6,6 +6,18 @@ "title": "Delete file" }, "description": "Delete a file from a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "branch": { @@ -18,7 +30,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "path": { "description": "Path to the file to delete", @@ -26,7 +39,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -38,5 +52,93 @@ ], "type": "object" }, - "name": "delete_file" + "name": "delete_file", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commit": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "date": { + "description": "Commit timestamp in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "committer": { + "additionalProperties": false, + "properties": { + "date": { + "description": "Commit timestamp in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "html_url": { + "type": "string" + }, + "message": { + "type": "string" + }, + "parents": { + "description": "Parent commit SHAs in Git parent order.", + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "sha": { + "type": "string" + }, + "tree_sha": { + "type": "string" + } + }, + "required": [ + "sha" + ], + "type": [ + "null", + "object" + ] + }, + "content": { + "type": "null" + } + }, + "required": [ + "commit", + "content" + ], + "type": [ + "null", + "object" + ] + } } \ 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 index f99f5d5632..157857d3d7 100644 --- a/pkg/github/__toolsnaps__/delete_pending_pull_request_review.snap +++ b/pkg/github/__toolsnaps__/delete_pending_pull_request_review.snap @@ -7,11 +7,24 @@ "title": "Delete Pending Pull Request Review" }, "description": "Delete a pending pull request review.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "pullNumber": { "description": "The pull request number", @@ -20,7 +33,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -30,5 +44,20 @@ ], "type": "object" }, - "name": "delete_pending_pull_request_review" + "name": "delete_pending_pull_request_review", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_repository.snap b/pkg/github/__toolsnaps__/delete_repository.snap index a75845b3f4..c510027db1 100644 --- a/pkg/github/__toolsnaps__/delete_repository.snap +++ b/pkg/github/__toolsnaps__/delete_repository.snap @@ -6,15 +6,29 @@ "title": "Delete repository" }, "description": "Delete a GitHub repository after the user confirms the exact owner/repository name", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -23,5 +37,20 @@ ], "type": "object" }, - "name": "delete_repository" + "name": "delete_repository", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/discussion_comment_write.snap b/pkg/github/__toolsnaps__/discussion_comment_write.snap index eb730b48e7..7eb56a7c16 100644 --- a/pkg/github/__toolsnaps__/discussion_comment_write.snap +++ b/pkg/github/__toolsnaps__/discussion_comment_write.snap @@ -46,5 +46,42 @@ ], "type": "object" }, - "name": "discussion_comment_write" + "name": "discussion_comment_write", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "discussionID": { + "type": "string" + }, + "discussionURL": { + "type": "string" + } + }, + "required": [ + "discussionID", + "discussionURL" + ], + "type": "object" + } + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/dismiss_notification.snap b/pkg/github/__toolsnaps__/dismiss_notification.snap index 6b2e38afd7..16e6394fb2 100644 --- a/pkg/github/__toolsnaps__/dismiss_notification.snap +++ b/pkg/github/__toolsnaps__/dismiss_notification.snap @@ -26,5 +26,17 @@ ], "type": "object" }, - "name": "dismiss_notification" + "name": "dismiss_notification", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap b/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap index ac95fd4138..c83c253812 100644 --- a/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap +++ b/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap @@ -20,14 +20,14 @@ "type": "string" }, "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, + "description": "Page number for pagination (min 0). Zero is forwarded for the GitHub API default.", + "minimum": 0, "type": "number" }, "perPage": { - "description": "Results per page for pagination (min 1, max 100)", + "description": "Results per page for pagination (min 0, max 100). Zero is forwarded for the GitHub API default.", "maximum": 100, - "minimum": 1, + "minimum": 0, "type": "number" }, "repo": { @@ -42,5 +42,66 @@ ], "type": "object" }, - "name": "find_duplicate" + "name": "find_duplicate", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "confidence": { + "description": "The API-provided confidence category.", + "type": "string" + }, + "issue": { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "The web URL of the candidate issue.", + "type": "string" + }, + "number": { + "description": "The candidate issue number.", + "type": "integer" + }, + "state": { + "description": "The candidate issue state.", + "type": "string" + }, + "title": { + "description": "The sanitized candidate issue title.", + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "html_url" + ], + "type": "object" + }, + "likely_duplicate": { + "description": "Whether the API considers this candidate a likely duplicate.", + "type": "boolean" + }, + "score": { + "description": "Similarity score on the API-defined scale; null when the API does not return a score.", + "type": [ + "null", + "number" + ] + } + }, + "required": [ + "issue", + "score", + "confidence", + "likely_duplicate" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/fork_repository.snap b/pkg/github/__toolsnaps__/fork_repository.snap index 1373ef0519..e65da439cd 100644 --- a/pkg/github/__toolsnaps__/fork_repository.snap +++ b/pkg/github/__toolsnaps__/fork_repository.snap @@ -25,11 +25,13 @@ }, "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -38,5 +40,40 @@ ], "type": "object" }, - "name": "fork_repository" + "name": "fork_repository", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_code_quality_finding.snap b/pkg/github/__toolsnaps__/get_code_quality_finding.snap index 84ccb948e4..6612c1c8a1 100644 --- a/pkg/github/__toolsnaps__/get_code_quality_finding.snap +++ b/pkg/github/__toolsnaps__/get_code_quality_finding.snap @@ -27,5 +27,136 @@ ], "type": "object" }, - "name": "get_code_quality_finding" + "name": "get_code_quality_finding", + "outputSchema": { + "additionalProperties": false, + "properties": { + "created_at": { + "description": "Finding creation time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "location": { + "additionalProperties": false, + "properties": { + "end_column": { + "type": [ + "null", + "integer" + ] + }, + "end_line": { + "type": [ + "null", + "integer" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "start_column": { + "type": [ + "null", + "integer" + ] + }, + "start_line": { + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "message": { + "additionalProperties": false, + "properties": { + "markdown": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "text", + "markdown" + ], + "type": [ + "null", + "object" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "rule": { + "additionalProperties": false, + "properties": { + "category": { + "type": [ + "null", + "string" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "help": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + } } \ 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 907870b03d..cd5a32dd0a 100644 --- a/pkg/github/__toolsnaps__/get_code_scanning_alert.snap +++ b/pkg/github/__toolsnaps__/get_code_scanning_alert.snap @@ -27,5 +27,168 @@ ], "type": "object" }, - "name": "get_code_scanning_alert" + "name": "get_code_scanning_alert", + "outputSchema": { + "additionalProperties": false, + "properties": { + "dismissed_comment": { + "type": [ + "null", + "string" + ] + }, + "dismissed_reason": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "most_recent_instance": { + "additionalProperties": false, + "properties": { + "commit_sha": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "location": { + "additionalProperties": false, + "properties": { + "end_column": { + "type": [ + "null", + "integer" + ] + }, + "end_line": { + "type": [ + "null", + "integer" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "start_column": { + "type": [ + "null", + "integer" + ] + }, + "start_line": { + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "message": { + "type": [ + "null", + "string" + ] + }, + "ref": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "rule": { + "additionalProperties": false, + "properties": { + "description": { + "type": [ + "null", + "string" + ] + }, + "help": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "security_severity_level": { + "description": "Security impact level, distinct from rule diagnostic severity.", + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_commit.snap b/pkg/github/__toolsnaps__/get_commit.snap index ad6a805515..8aef83dbc5 100644 --- a/pkg/github/__toolsnaps__/get_commit.snap +++ b/pkg/github/__toolsnaps__/get_commit.snap @@ -5,6 +5,18 @@ "title": "Get commit details" }, "description": "Get details for a commit from a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "detail": { @@ -19,7 +31,8 @@ }, "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "page": { "description": "Page number for pagination (min 1)", @@ -34,7 +47,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "sha": { "description": "Commit SHA, branch name, or tag name", @@ -48,5 +62,311 @@ ], "type": "object" }, - "name": "get_commit" + "name": "get_commit", + "outputSchema": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "commit": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "committer": { + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + }, + "committer": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "files": { + "items": { + "additionalProperties": false, + "properties": { + "additions": { + "type": "integer" + }, + "changes": { + "type": "integer" + }, + "deletions": { + "type": "integer" + }, + "filename": { + "type": "string" + }, + "patch": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "filename" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "html_url": { + "type": "string" + }, + "sha": { + "type": "string" + }, + "stats": { + "additionalProperties": false, + "properties": { + "additions": { + "type": "integer" + }, + "deletions": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "sha", + "html_url" + ], + "type": "object" + } } \ 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 383e86a29e..a4fa34feae 100644 --- a/pkg/github/__toolsnaps__/get_dependabot_alert.snap +++ b/pkg/github/__toolsnaps__/get_dependabot_alert.snap @@ -27,5 +27,181 @@ ], "type": "object" }, - "name": "get_dependabot_alert" + "name": "get_dependabot_alert", + "outputSchema": { + "additionalProperties": false, + "properties": { + "dependency": { + "additionalProperties": false, + "properties": { + "manifest_path": { + "type": [ + "null", + "string" + ] + }, + "package": { + "additionalProperties": false, + "properties": { + "ecosystem": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "scope": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "dismissed_comment": { + "type": [ + "null", + "string" + ] + }, + "dismissed_reason": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "security_advisory": { + "additionalProperties": false, + "properties": { + "classification": { + "description": "GitHub advisory classification, such as malware or general.", + "type": [ + "null", + "string" + ] + }, + "cve_id": { + "type": [ + "null", + "string" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "ghsa_id": { + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "summary": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "security_vulnerability": { + "additionalProperties": false, + "properties": { + "first_patched_version": { + "description": "Earliest version fixing the affected package.", + "type": [ + "null", + "string" + ] + }, + "package": { + "additionalProperties": false, + "properties": { + "ecosystem": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "vulnerable_version_range": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_discussion.snap b/pkg/github/__toolsnaps__/get_discussion.snap index 3f538d4e97..5269bc7b7e 100644 --- a/pkg/github/__toolsnaps__/get_discussion.snap +++ b/pkg/github/__toolsnaps__/get_discussion.snap @@ -27,5 +27,62 @@ ], "type": "object" }, - "name": "get_discussion" + "name": "get_discussion", + "outputSchema": { + "additionalProperties": false, + "properties": { + "answerChosenAt": { + "description": "Answer selection time (RFC3339), when selected.", + "type": [ + "null", + "string" + ] + }, + "body": { + "type": "string" + }, + "category": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "closed": { + "type": "boolean" + }, + "createdAt": { + "description": "Creation time (RFC3339).", + "type": "string" + }, + "html_url": { + "type": "string" + }, + "isAnswered": { + "type": "boolean" + }, + "number": { + "type": "integer" + }, + "title": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "body", + "html_url", + "closed", + "isAnswered", + "createdAt", + "category" + ], + "type": "object" + } } \ 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 a2705c9bf1..59cd8beed2 100644 --- a/pkg/github/__toolsnaps__/get_discussion_comments.snap +++ b/pkg/github/__toolsnaps__/get_discussion_comments.snap @@ -41,5 +41,97 @@ ], "type": "object" }, - "name": "get_discussion_comments" + "name": "get_discussion_comments", + "outputSchema": { + "additionalProperties": false, + "properties": { + "comments": { + "items": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isAnswer": { + "type": "boolean" + }, + "replies": { + "items": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isAnswer": { + "type": "boolean" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "replyTotalCount": { + "description": "Total replies, including replies beyond the returned maximum of 100.", + "type": "integer" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "endCursor": { + "type": "string" + }, + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "startCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage", + "startCursor", + "endCursor" + ], + "type": "object" + }, + "totalCount": { + "type": "integer" + } + }, + "required": [ + "comments", + "pageInfo", + "totalCount" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_file_blame.snap b/pkg/github/__toolsnaps__/get_file_blame.snap index 33a09fea88..125f16c349 100644 --- a/pkg/github/__toolsnaps__/get_file_blame.snap +++ b/pkg/github/__toolsnaps__/get_file_blame.snap @@ -5,6 +5,18 @@ "title": "Get file blame information" }, "description": "Get git blame information for a file, showing the commit that last modified each line. Ranges share commit metadata via the top-level 'commits' map keyed by SHA. Use 'start_line'/'end_line' to restrict the result to a window of the file, and 'perPage'/'after' to cursor-page through returned ranges. Matching ranges are capped at 1000; when the cap is hit 'truncated' is set to true and 'total_ranges' reports the pre-cap match count.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "after": { @@ -18,7 +30,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "path": { "description": "Path to the file in the repository, relative to the repository root", @@ -36,7 +49,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "start_line": { "description": "Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window.", @@ -51,5 +65,152 @@ ], "type": "object" }, - "name": "get_file_blame" + "name": "get_file_blame", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "commits": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + }, + "login": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": "string" + }, + "url": { + "type": [ + "null", + "string" + ] + } + }, + "required": [ + "name", + "email" + ], + "type": "object" + }, + "committed_date": { + "description": "Commit timestamp in RFC3339 format.", + "type": "string" + }, + "message_headline": { + "type": "string" + }, + "sha": { + "type": "string" + } + }, + "required": [ + "sha", + "message_headline", + "committed_date", + "author" + ], + "type": "object" + }, + "type": "object" + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "endCursor": { + "type": "string" + }, + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "startCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage" + ], + "type": "object" + }, + "path": { + "type": "string" + }, + "ranges": { + "items": { + "additionalProperties": false, + "properties": { + "age": { + "description": "Relative commit age among commits touching this file; 0 is newest.", + "type": "integer" + }, + "commit_sha": { + "type": "string" + }, + "ending_line": { + "description": "Last line of the inclusive, one-based range.", + "type": "integer" + }, + "starting_line": { + "description": "First line of the inclusive, one-based range.", + "type": "integer" + } + }, + "required": [ + "starting_line", + "ending_line", + "age", + "commit_sha" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "ref": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "total_ranges": { + "description": "Matching ranges before pagination or truncation.", + "type": "integer" + }, + "truncated": { + "description": "Whether the 1000-range safety limit was reached.", + "type": "boolean" + } + }, + "required": [ + "repository", + "path", + "ref", + "ranges", + "commits", + "pageInfo", + "total_ranges" + ], + "type": "object" + } + ] + } } \ 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 dec933c94d..fa845c7d50 100644 --- a/pkg/github/__toolsnaps__/get_file_contents.snap +++ b/pkg/github/__toolsnaps__/get_file_contents.snap @@ -5,6 +5,18 @@ "title": "Get file or directory contents" }, "description": "Get the contents of a file or directory from a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "fields": { @@ -27,7 +39,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "path": { "default": "/", @@ -40,7 +53,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "sha": { "description": "Accepts optional commit SHA. If specified, it will be used instead of ref", @@ -53,5 +67,236 @@ ], "type": "object" }, - "name": "get_file_contents" + "name": "get_file_contents", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "items": { + "additionalProperties": false, + "properties": { + "download_url": { + "type": [ + "null", + "string" + ] + }, + "git_url": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "sha": { + "type": [ + "null", + "string" + ] + }, + "size": { + "description": "File size in bytes.", + "type": [ + "null", + "integer" + ] + }, + "submodule_git_url": { + "description": "Git remote URL for a submodule.", + "type": [ + "null", + "string" + ] + }, + "target": { + "type": [ + "null", + "string" + ] + }, + "type": { + "type": [ + "null", + "string" + ] + }, + "url": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": "array" + }, + { + "additionalProperties": false, + "properties": { + "content": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "text": { + "type": "string" + }, + "type": { + "const": "text", + "type": "string" + } + }, + "required": [ + "type", + "text" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "resource": { + "additionalProperties": false, + "oneOf": [ + { + "not": { + "required": [ + "blob" + ] + }, + "required": [ + "text" + ] + }, + { + "not": { + "required": [ + "text" + ] + }, + "required": [ + "blob" + ] + }, + { + "not": { + "anyOf": [ + { + "required": [ + "text" + ] + }, + { + "required": [ + "blob" + ] + } + ] + } + } + ], + "properties": { + "blob": { + "description": "Base64-encoded file bytes.", + "type": [ + "null", + "string" + ] + }, + "mimeType": { + "type": "string" + }, + "text": { + "type": [ + "null", + "string" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "type": { + "const": "resource", + "type": "string" + } + }, + "required": [ + "type", + "resource" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "size": { + "description": "File size in bytes.", + "type": [ + "null", + "integer" + ] + }, + "title": { + "type": "string" + }, + "type": { + "const": "resource_link", + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "type", + "uri", + "name" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "content" + ], + "type": "object" + } + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_gist.snap b/pkg/github/__toolsnaps__/get_gist.snap index b4c053b549..61a80ccee6 100644 --- a/pkg/github/__toolsnaps__/get_gist.snap +++ b/pkg/github/__toolsnaps__/get_gist.snap @@ -17,5 +17,97 @@ ], "type": "object" }, - "name": "get_gist" + "name": "get_gist", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "comments": { + "type": "integer" + }, + "created_at": { + "description": "Creation time in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "description": { + "type": "string" + }, + "files": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "content": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "language": { + "type": "string" + }, + "raw_url": { + "type": "string" + }, + "size": { + "description": "File size in bytes.", + "type": "integer" + }, + "type": { + "description": "File MIME type, when available.", + "type": "string" + } + }, + "type": "object" + }, + "type": "object" + }, + "git_pull_url": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "string" + }, + "owner": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "public": { + "description": "True for a public gist; false for a secret, URL-accessible gist.", + "type": "boolean" + }, + "updated_at": { + "description": "Last update time in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "type": "object" + } + ] + } } \ 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 74ec794740..647701421b 100644 --- a/pkg/github/__toolsnaps__/get_global_security_advisory.snap +++ b/pkg/github/__toolsnaps__/get_global_security_advisory.snap @@ -17,5 +17,168 @@ ], "type": "object" }, - "name": "get_global_security_advisory" + "name": "get_global_security_advisory", + "outputSchema": { + "additionalProperties": false, + "properties": { + "cve_id": { + "type": [ + "null", + "string" + ] + }, + "cwe_ids": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "ghsa_id": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "published_at": { + "description": "Advisory publication time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "source_code_location": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "summary": { + "type": [ + "null", + "string" + ] + }, + "type": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last advisory update time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "vulnerabilities": { + "items": { + "additionalProperties": false, + "properties": { + "first_patched_version": { + "description": "Earliest version fixing the affected package.", + "type": [ + "null", + "string" + ] + }, + "package": { + "additionalProperties": false, + "properties": { + "ecosystem": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "patched_versions": { + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "vulnerable_functions": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "vulnerable_version_range": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "withdrawn_at": { + "description": "Withdrawal time, when withdrawn (RFC 3339).", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_job_logs_actions.snap b/pkg/github/__toolsnaps__/get_job_logs_actions.snap new file mode 100644 index 0000000000..b49924f2c0 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_job_logs_actions.snap @@ -0,0 +1,237 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Get GitHub Actions workflow job logs" + }, + "description": "Get logs for GitHub Actions workflow jobs.\nUse this tool to retrieve logs for a specific job or all failed jobs in a workflow run.\nFor single job logs, provide job_id. For all failed jobs in a run, provide run_id with failed_only=true.\n", + "inputSchema": { + "properties": { + "failed_only": { + "description": "When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided.", + "type": "boolean" + }, + "job_id": { + "description": "The unique identifier of the workflow job. Required when getting logs for a single job.", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "return_content": { + "description": "Returns actual log content instead of URLs", + "type": "boolean" + }, + "run_id": { + "description": "The unique identifier of the workflow run. Required when failed_only is true to get logs for all failed jobs in the run.", + "type": "number" + }, + "tail_lines": { + "default": 500, + "description": "Number of lines to return from the end of the log", + "type": "number" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "get_job_logs", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "job_id": { + "type": "integer" + }, + "job_name": { + "type": "string" + }, + "logs_content": { + "type": "string" + }, + "message": { + "type": "string" + }, + "original_length": { + "description": "Number of log lines before truncation.", + "type": "integer" + } + }, + "required": [ + "job_id", + "logs_content", + "message", + "original_length" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "job_id": { + "type": "integer" + }, + "job_name": { + "type": "string" + }, + "logs_url": { + "type": "string" + }, + "message": { + "type": "string" + }, + "note": { + "type": "string" + } + }, + "required": [ + "job_id", + "logs_url", + "message", + "note" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failed_jobs": { + "type": "integer" + }, + "logs": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "job_id": { + "type": "integer" + }, + "job_name": { + "type": "string" + }, + "logs_content": { + "type": "string" + }, + "message": { + "type": "string" + }, + "original_length": { + "description": "Number of log lines before truncation.", + "type": "integer" + } + }, + "required": [ + "job_id", + "logs_content", + "message", + "original_length" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "job_id": { + "type": "integer" + }, + "job_name": { + "type": "string" + }, + "logs_url": { + "type": "string" + }, + "message": { + "type": "string" + }, + "note": { + "type": "string" + } + }, + "required": [ + "job_id", + "logs_url", + "message", + "note" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "error": { + "type": "string" + }, + "job_id": { + "type": "integer" + }, + "job_name": { + "type": "string" + } + }, + "required": [ + "error", + "job_id", + "job_name" + ], + "type": "object" + } + ] + }, + "type": [ + "null", + "array" + ] + }, + "message": { + "type": "string" + }, + "return_format": { + "additionalProperties": false, + "properties": { + "content": { + "type": "boolean" + }, + "urls": { + "type": "boolean" + } + }, + "required": [ + "content", + "urls" + ], + "type": [ + "null", + "object" + ] + }, + "run_id": { + "type": "integer" + }, + "total_jobs": { + "type": "integer" + } + }, + "required": [ + "failed_jobs", + "message", + "run_id", + "total_jobs" + ], + "type": "object" + } + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_label.snap b/pkg/github/__toolsnaps__/get_label.snap index b258038506..6cd1ddb6aa 100644 --- a/pkg/github/__toolsnaps__/get_label.snap +++ b/pkg/github/__toolsnaps__/get_label.snap @@ -27,5 +27,26 @@ ], "type": "object" }, - "name": "get_label" + "name": "get_label", + "outputSchema": { + "additionalProperties": false, + "properties": { + "color": { + "description": "Hex RGB without leading #.", + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "color", + "description" + ], + "type": "object" + } } \ 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 4d4f0d103e..7d52f91e2b 100644 --- a/pkg/github/__toolsnaps__/get_latest_release.snap +++ b/pkg/github/__toolsnaps__/get_latest_release.snap @@ -5,15 +5,29 @@ "title": "Get latest release" }, "description": "Get the latest release in a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -22,5 +36,142 @@ ], "type": "object" }, - "name": "get_latest_release" + "name": "get_latest_release", + "outputSchema": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "body": { + "type": "string" + }, + "draft": { + "description": "Whether the release is unpublished.", + "type": "boolean" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "prerelease": { + "description": "Whether the release is a prerelease.", + "type": "boolean" + }, + "published_at": { + "description": "Publication time in RFC3339 format.", + "type": "string" + }, + "tag_name": { + "type": "string" + } + }, + "required": [ + "id", + "tag_name", + "html_url", + "prerelease", + "draft" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_me.snap b/pkg/github/__toolsnaps__/get_me.snap index 4196c6734d..f8ffef8004 100644 --- a/pkg/github/__toolsnaps__/get_me.snap +++ b/pkg/github/__toolsnaps__/get_me.snap @@ -18,5 +18,96 @@ "properties": {}, "type": "object" }, - "name": "get_me" + "name": "get_me", + "outputSchema": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + } } \ 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 2929ec2fc9..333d05dfed 100644 --- a/pkg/github/__toolsnaps__/get_notification_details.snap +++ b/pkg/github/__toolsnaps__/get_notification_details.snap @@ -17,5 +17,112 @@ ], "type": "object" }, - "name": "get_notification_details" + "name": "get_notification_details", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": [ + "null", + "string" + ] + }, + "last_read_at": { + "description": "Last acknowledgement time (RFC3339).", + "type": [ + "null", + "string" + ] + }, + "reason": { + "description": "Notification reason reported by GitHub; new provider values are preserved.", + "type": [ + "null", + "string" + ] + }, + "repository": { + "additionalProperties": false, + "properties": { + "full_name": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "private": { + "type": [ + "null", + "boolean" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "subject": { + "additionalProperties": false, + "properties": { + "title": { + "type": [ + "null", + "string" + ] + }, + "type": { + "description": "Subject type reported by GitHub; new provider values are preserved.", + "type": [ + "null", + "string" + ] + }, + "url": { + "description": "API reference to the underlying notification subject.", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "unread": { + "type": [ + "null", + "boolean" + ] + }, + "updated_at": { + "description": "Last update time (RFC3339).", + "type": [ + "null", + "string" + ] + } + }, + "type": "object" + } } \ 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 60e0a3a30b..b447ea2cba 100644 --- a/pkg/github/__toolsnaps__/get_release_by_tag.snap +++ b/pkg/github/__toolsnaps__/get_release_by_tag.snap @@ -5,15 +5,29 @@ "title": "Get a release by tag name" }, "description": "Get a specific release by its tag name in a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "tag": { "description": "Tag name (e.g., 'v1.0.0')", @@ -27,5 +41,142 @@ ], "type": "object" }, - "name": "get_release_by_tag" + "name": "get_release_by_tag", + "outputSchema": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "body": { + "type": "string" + }, + "draft": { + "description": "Whether the release is unpublished.", + "type": "boolean" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "prerelease": { + "description": "Whether the release is a prerelease.", + "type": "boolean" + }, + "published_at": { + "description": "Publication time in RFC3339 format.", + "type": "string" + }, + "tag_name": { + "type": "string" + } + }, + "required": [ + "id", + "tag_name", + "html_url", + "prerelease", + "draft" + ], + "type": [ + "null", + "object" + ] + } } \ 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 6ba368f2f2..0a87d92ba2 100644 --- a/pkg/github/__toolsnaps__/get_repository_tree.snap +++ b/pkg/github/__toolsnaps__/get_repository_tree.snap @@ -35,5 +35,92 @@ ], "type": "object" }, - "name": "get_repository_tree" + "name": "get_repository_tree", + "outputSchema": { + "additionalProperties": false, + "properties": { + "count": { + "type": "integer" + }, + "owner": { + "type": "string" + }, + "recursive": { + "type": "boolean" + }, + "repo": { + "type": "string" + }, + "sha": { + "type": "string" + }, + "tree": { + "items": { + "additionalProperties": false, + "properties": { + "mode": { + "description": "Git file mode in octal; empty if absent in the API response.", + "enum": [ + "100644", + "100755", + "040000", + "160000", + "120000", + "" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "sha": { + "type": "string" + }, + "size": { + "description": "Blob size in bytes, when available.", + "type": "integer" + }, + "type": { + "description": "Git object type; empty if absent in the API response.", + "enum": [ + "blob", + "tree", + "commit", + "" + ], + "type": "string" + } + }, + "required": [ + "path", + "type", + "mode", + "sha" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "tree_sha": { + "type": "string" + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "sha", + "truncated", + "tree", + "tree_sha", + "owner", + "repo", + "recursive", + "count" + ], + "type": "object" + } } \ 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 7d04cc5ca9..81bcd0bdd0 100644 --- a/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap +++ b/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap @@ -27,5 +27,135 @@ ], "type": "object" }, - "name": "get_secret_scanning_alert" + "name": "get_secret_scanning_alert", + "outputSchema": { + "additionalProperties": false, + "properties": { + "first_location_detected": { + "additionalProperties": false, + "properties": { + "commit_sha": { + "type": [ + "null", + "string" + ] + }, + "end_column": { + "type": [ + "null", + "integer" + ] + }, + "end_line": { + "type": [ + "null", + "integer" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "start_column": { + "type": [ + "null", + "integer" + ] + }, + "start_line": { + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "has_more_locations": { + "description": "Whether additional secret locations exist beyond the first location.", + "type": [ + "null", + "boolean" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "is_base64_encoded": { + "description": "Whether the detected secret is base64 encoded.", + "type": [ + "null", + "boolean" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "push_protection_bypassed": { + "description": "Whether push protection was bypassed for this secret.", + "type": [ + "null", + "boolean" + ] + }, + "resolution": { + "type": [ + "null", + "string" + ] + }, + "resolution_comment": { + "type": [ + "null", + "string" + ] + }, + "secret": { + "type": [ + "null", + "string" + ] + }, + "secret_type": { + "type": [ + "null", + "string" + ] + }, + "secret_type_display_name": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "validity": { + "description": "Credential validity reported by GitHub: active, inactive, or unknown.", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_tag.snap b/pkg/github/__toolsnaps__/get_tag.snap index bb2751c970..5fafda0217 100644 --- a/pkg/github/__toolsnaps__/get_tag.snap +++ b/pkg/github/__toolsnaps__/get_tag.snap @@ -5,15 +5,29 @@ "title": "Get tag details" }, "description": "Get details about a specific git tag in a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "tag": { "description": "Tag name", @@ -27,5 +41,101 @@ ], "type": "object" }, - "name": "get_tag" + "name": "get_tag", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "object": { + "additionalProperties": false, + "properties": { + "sha": { + "type": "string" + }, + "type": { + "description": "Git object kind: commit, tag, tree, or blob.", + "type": "string" + } + }, + "required": [ + "type", + "sha" + ], + "type": "object" + }, + "ref": { + "description": "Fully qualified reference, such as refs/heads/main.", + "type": "string" + } + }, + "required": [ + "ref", + "object" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + }, + "object": { + "additionalProperties": false, + "properties": { + "sha": { + "type": "string" + }, + "type": { + "description": "Git object kind: commit, tag, tree, or blob.", + "type": "string" + } + }, + "required": [ + "type", + "sha" + ], + "type": [ + "null", + "object" + ] + }, + "sha": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "tagger": { + "additionalProperties": false, + "properties": { + "date": { + "description": "Tagger time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "tag", + "sha" + ], + "type": "object" + } + ] + } } \ 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 a95be1ceda..4d11c5d3b1 100644 --- a/pkg/github/__toolsnaps__/get_team_members.snap +++ b/pkg/github/__toolsnaps__/get_team_members.snap @@ -22,5 +22,14 @@ ], "type": "object" }, - "name": "get_team_members" + "name": "get_team_members", + "outputSchema": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_teams.snap b/pkg/github/__toolsnaps__/get_teams.snap index 49bc62ed9f..ca0debf9b6 100644 --- a/pkg/github/__toolsnaps__/get_teams.snap +++ b/pkg/github/__toolsnaps__/get_teams.snap @@ -14,5 +14,50 @@ }, "type": "object" }, - "name": "get_teams" + "name": "get_teams", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "org": { + "type": "string" + }, + "teams": { + "items": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "name", + "slug", + "description" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "org", + "teams" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/hide_issue_comment.snap b/pkg/github/__toolsnaps__/hide_issue_comment.snap new file mode 100644 index 0000000000..a9108e1106 --- /dev/null +++ b/pkg/github/__toolsnaps__/hide_issue_comment.snap @@ -0,0 +1,82 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Hide Issue Comment" + }, + "description": "Hide (minimize) a comment on an issue, or a conversation comment on a pull request. Requires triage or write access to the repository, or being its author.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "classifier": { + "description": "The reason for hiding the comment", + "enum": [ + "SPAM", + "ABUSE", + "OFF_TOPIC", + "OUTDATED", + "DUPLICATE", + "RESOLVED", + "LOW_QUALITY" + ], + "type": "string" + }, + "comment_id": { + "description": "The numeric ID of the issue or pull request conversation comment", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string", + "x-mcp-header": "owner" + }, + "repo": { + "description": "Repository name", + "type": "string", + "x-mcp-header": "repo" + } + }, + "required": [ + "owner", + "repo", + "comment_id", + "classifier" + ], + "type": "object" + }, + "name": "hide_issue_comment", + "outputSchema": { + "additionalProperties": false, + "properties": { + "is_minimized": { + "type": "boolean" + }, + "minimized_reason": { + "description": "GitHub's lowercase, hyphenated reason (for example off-topic); omitted when unhidden.", + "type": "string" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "node_id", + "is_minimized" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/hide_pull_request_review.snap b/pkg/github/__toolsnaps__/hide_pull_request_review.snap new file mode 100644 index 0000000000..e9b8d3fc33 --- /dev/null +++ b/pkg/github/__toolsnaps__/hide_pull_request_review.snap @@ -0,0 +1,74 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Hide Pull Request Review" + }, + "description": "Hide (minimize) the body of a submitted pull request review. Requires triage or write access to the repository, or being its author.", + "inputSchema": { + "properties": { + "classifier": { + "description": "The reason for hiding the comment", + "enum": [ + "SPAM", + "ABUSE", + "OFF_TOPIC", + "OUTDATED", + "DUPLICATE", + "RESOLVED", + "LOW_QUALITY" + ], + "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" + }, + "review_id": { + "description": "The numeric ID of the pull request review", + "minimum": 1, + "type": "number" + } + }, + "required": [ + "owner", + "repo", + "pullNumber", + "review_id", + "classifier" + ], + "type": "object" + }, + "name": "hide_pull_request_review", + "outputSchema": { + "additionalProperties": false, + "properties": { + "is_minimized": { + "type": "boolean" + }, + "minimized_reason": { + "description": "GitHub's lowercase, hyphenated reason (for example off-topic); omitted when unhidden.", + "type": "string" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "node_id", + "is_minimized" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/hide_pull_request_review_comment.snap b/pkg/github/__toolsnaps__/hide_pull_request_review_comment.snap new file mode 100644 index 0000000000..48fdb9eac4 --- /dev/null +++ b/pkg/github/__toolsnaps__/hide_pull_request_review_comment.snap @@ -0,0 +1,68 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Hide Pull Request Review Comment" + }, + "description": "Hide (minimize) an inline review comment on a pull request diff. Requires triage or write access to the repository, or being its author.", + "inputSchema": { + "properties": { + "classifier": { + "description": "The reason for hiding the comment", + "enum": [ + "SPAM", + "ABUSE", + "OFF_TOPIC", + "OUTDATED", + "DUPLICATE", + "RESOLVED", + "LOW_QUALITY" + ], + "type": "string" + }, + "comment_id": { + "description": "The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "comment_id", + "classifier" + ], + "type": "object" + }, + "name": "hide_pull_request_review_comment", + "outputSchema": { + "additionalProperties": false, + "properties": { + "is_minimized": { + "type": "boolean" + }, + "minimized_reason": { + "description": "GitHub's lowercase, hyphenated reason (for example off-topic); omitted when unhidden.", + "type": "string" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "node_id", + "is_minimized" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_dependency_read_ff_issue_dependencies.snap b/pkg/github/__toolsnaps__/issue_dependency_read_ff_issue_dependencies.snap index cf6c3662f1..a8ce1e34b4 100644 --- a/pkg/github/__toolsnaps__/issue_dependency_read_ff_issue_dependencies.snap +++ b/pkg/github/__toolsnaps__/issue_dependency_read_ff_issue_dependencies.snap @@ -47,5 +47,75 @@ ], "type": "object" }, - "name": "issue_dependency_read" + "name": "issue_dependency_read", + "outputSchema": { + "additionalProperties": false, + "properties": { + "issues": { + "description": "Related issues in the requested dependency direction.", + "items": { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "The web URL of the related issue.", + "type": "string" + }, + "number": { + "description": "The related issue number.", + "type": "integer" + }, + "repository": { + "description": "The related repository in owner/name form.", + "type": "string" + }, + "state": { + "description": "The related issue state.", + "type": "string" + }, + "title": { + "description": "The sanitized title of the related issue.", + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "html_url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "hasNextPage": { + "description": "Whether another page of related issues is available.", + "type": "boolean" + }, + "nextPage": { + "description": "The next page number, or 0 when no next page exists.", + "type": "integer" + } + }, + "required": [ + "hasNextPage", + "nextPage" + ], + "type": "object" + } + }, + "required": [ + "issues", + "pageInfo" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_dependency_write_ff_issue_dependencies.snap b/pkg/github/__toolsnaps__/issue_dependency_write_ff_issue_dependencies.snap index b907dff6cf..962db337c4 100644 --- a/pkg/github/__toolsnaps__/issue_dependency_write_ff_issue_dependencies.snap +++ b/pkg/github/__toolsnaps__/issue_dependency_write_ff_issue_dependencies.snap @@ -58,5 +58,87 @@ ], "type": "object" }, - "name": "issue_dependency_write" + "name": "issue_dependency_write", + "outputSchema": { + "additionalProperties": false, + "properties": { + "blocked_issue": { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "The web URL of the issue.", + "type": "string" + }, + "number": { + "description": "The issue number.", + "type": "integer" + }, + "repository": { + "description": "The repository in owner/name form.", + "type": "string" + }, + "state": { + "description": "The issue state.", + "type": "string" + }, + "title": { + "description": "The sanitized issue title.", + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "html_url" + ], + "type": "object" + }, + "blocking_issue": { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "The web URL of the issue.", + "type": "string" + }, + "number": { + "description": "The issue number.", + "type": "integer" + }, + "repository": { + "description": "The repository in owner/name form.", + "type": "string" + }, + "state": { + "description": "The issue state.", + "type": "string" + }, + "title": { + "description": "The sanitized issue title.", + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "html_url" + ], + "type": "object" + }, + "message": { + "description": "Whether the dependency relationship was added or removed.", + "type": "string" + } + }, + "required": [ + "blocked_issue", + "blocking_issue", + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_read.snap b/pkg/github/__toolsnaps__/issue_read.snap index faf6085a5e..4a4356bfe5 100644 --- a/pkg/github/__toolsnaps__/issue_read.snap +++ b/pkg/github/__toolsnaps__/issue_read.snap @@ -50,5 +50,893 @@ ], "type": "object" }, - "name": "issue_read" + "name": "issue_read", + "outputSchema": { + "additionalProperties": false, + "oneOf": [ + { + "properties": { + "comments": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get" + ] + }, + "parent": { + "not": { + "description": "Any value." + } + }, + "sub_issues": { + "not": { + "description": "Any value." + } + }, + "totalCount": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "issue" + ] + }, + { + "properties": { + "issue": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_comments" + ] + }, + "parent": { + "not": { + "description": "Any value." + } + }, + "sub_issues": { + "not": { + "description": "Any value." + } + }, + "totalCount": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "comments" + ] + }, + { + "properties": { + "comments": { + "not": { + "description": "Any value." + } + }, + "issue": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_sub_issues" + ] + }, + "parent": { + "not": { + "description": "Any value." + } + }, + "totalCount": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "sub_issues" + ] + }, + { + "properties": { + "comments": { + "not": { + "description": "Any value." + } + }, + "issue": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_parent" + ] + }, + "sub_issues": { + "not": { + "description": "Any value." + } + }, + "totalCount": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "parent" + ] + }, + { + "properties": { + "comments": { + "not": { + "description": "Any value." + } + }, + "issue": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_labels" + ] + }, + "parent": { + "not": { + "description": "Any value." + } + }, + "sub_issues": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "labels", + "totalCount" + ] + } + ], + "properties": { + "comments": { + "items": { + "additionalProperties": false, + "properties": { + "author_association": { + "type": "string" + }, + "body": { + "type": "string" + }, + "created_at": { + "description": "Creation time (RFC 3339).", + "type": "string" + }, + "html_url": { + "description": "Human-readable comment link.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "reactions": { + "additionalProperties": false, + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": [ + "null", + "object" + ] + }, + "updated_at": { + "description": "Last update time (RFC 3339).", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "id", + "html_url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "issue": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "author_association": { + "type": "string" + }, + "body": { + "type": "string" + }, + "closed_at": { + "description": "Closure time (RFC 3339), when closed.", + "type": "string" + }, + "closed_by": { + "type": "string" + }, + "closed_by_pull_requests": { + "additionalProperties": false, + "properties": { + "references": { + "items": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "references" + ], + "type": [ + "null", + "object" + ] + }, + "comments": { + "description": "Number of comments.", + "type": "integer" + }, + "created_at": { + "description": "Creation time (RFC 3339).", + "type": "string" + }, + "draft": { + "type": "boolean" + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "value": { + "type": "string" + }, + "values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "has_children": { + "type": [ + "null", + "boolean" + ] + }, + "has_parent": { + "type": [ + "null", + "boolean" + ] + }, + "html_url": { + "description": "Human-readable issue link.", + "type": "string" + }, + "issue_type": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "locked": { + "type": "boolean" + }, + "milestone": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "parent": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "url" + ], + "type": [ + "null", + "object" + ] + }, + "reactions": { + "additionalProperties": false, + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": [ + "null", + "object" + ] + }, + "state": { + "description": "Issue state (lowercase REST value).", + "enum": [ + "", + "open", + "closed" + ], + "type": "string" + }, + "state_reason": { + "enum": [ + "completed", + "not_planned", + "reopened", + "duplicate" + ], + "type": "string" + }, + "sub_issues_summary": { + "additionalProperties": false, + "properties": { + "completed": { + "description": "Number of completed sub-issues.", + "type": "integer" + }, + "percent_completed": { + "description": "Completed sub-issues as a percentage (0-100).", + "type": "integer" + }, + "total": { + "description": "Total number of sub-issues.", + "type": "integer" + } + }, + "required": [ + "total", + "completed", + "percent_completed" + ], + "type": [ + "null", + "object" + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "description": "Last update time (RFC 3339).", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "number", + "title", + "state", + "assignees" + ], + "type": [ + "null", + "object" + ] + }, + "labels": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "color", + "description", + "id", + "name" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "method": { + "enum": [ + "get", + "get_comments", + "get_sub_issues", + "get_parent", + "get_labels" + ], + "type": "string" + }, + "parent": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "description": "Parent issue state (uppercase GraphQL value).", + "enum": [ + "", + "OPEN", + "CLOSED" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "description": "Human-readable parent issue link.", + "type": "string" + } + }, + "required": [ + "number", + "repository", + "state", + "title", + "url" + ], + "type": [ + "null", + "object" + ] + }, + "sub_issues": { + "items": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "closed_at": { + "description": "Closure time (RFC 3339), when closed.", + "type": "string" + }, + "comments": { + "description": "Number of comments.", + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "description": "Creation time (RFC 3339).", + "type": "string" + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "description": "REST issue field type.", + "enum": [ + "text", + "number", + "date", + "single_select" + ], + "type": "string" + }, + "issue_field_id": { + "type": "integer" + }, + "value": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + }, + "required": [ + "issue_field_id", + "data_type", + "value" + ], + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "html_url": { + "description": "Human-readable issue link.", + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "issue_type": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "milestone": { + "type": "string" + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "state": { + "description": "Issue state (lowercase REST value).", + "enum": [ + "open", + "closed" + ], + "type": [ + "null", + "string" + ] + }, + "state_reason": { + "enum": [ + "completed", + "not_planned", + "reopened", + "duplicate" + ], + "type": [ + "null", + "string" + ] + }, + "sub_issues_summary": { + "additionalProperties": false, + "properties": { + "completed": { + "description": "Number of completed sub-issues.", + "type": "integer" + }, + "percent_completed": { + "description": "Completed sub-issues as a percentage (0-100).", + "type": "integer" + }, + "total": { + "description": "Total number of sub-issues.", + "type": "integer" + } + }, + "required": [ + "total", + "completed", + "percent_completed" + ], + "type": [ + "null", + "object" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last update time (RFC 3339).", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "assignees" + ], + "type": [ + "object", + "null" + ] + }, + "type": [ + "array", + "null" + ] + }, + "totalCount": { + "type": [ + "null", + "integer" + ] + } + }, + "required": [ + "method" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index 20cc730f51..dde73657f6 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -149,5 +149,84 @@ ], "type": "object" }, - "name": "issue_write" + "name": "issue_write", + "outputSchema": { + "additionalProperties": false, + "oneOf": [ + { + "properties": { + "reason": { + "not": { + "description": "Any value." + } + }, + "status": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "method", + "issue" + ] + }, + { + "properties": { + "issue": { + "not": { + "description": "Any value." + } + }, + "method": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "status", + "reason" + ] + } + ], + "properties": { + "issue": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + }, + "method": { + "enum": [ + "create", + "update" + ], + "type": "string" + }, + "reason": { + "type": "string" + }, + "status": { + "enum": [ + "awaiting_user_submission" + ], + "type": "string" + } + }, + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/label_write.snap b/pkg/github/__toolsnaps__/label_write.snap index e9fdcf0d83..21bfd8f8fe 100644 --- a/pkg/github/__toolsnaps__/label_write.snap +++ b/pkg/github/__toolsnaps__/label_write.snap @@ -50,5 +50,26 @@ ], "type": "object" }, - "name": "label_write" + "name": "label_write", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + }, + "method": { + "enum": [ + "create", + "update", + "delete" + ], + "type": "string" + } + }, + "required": [ + "method", + "message" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_branches_output.snap b/pkg/github/__toolsnaps__/list_branches_output.snap new file mode 100644 index 0000000000..147d9a7efe --- /dev/null +++ b/pkg/github/__toolsnaps__/list_branches_output.snap @@ -0,0 +1,30 @@ +{ + "inputSchema": null, + "name": "list_branches", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "protected": { + "type": "boolean" + }, + "sha": { + "type": "string" + } + }, + "required": [ + "name", + "sha", + "protected" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } +} \ 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 df67380a35..e33bcccb12 100644 --- a/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap +++ b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap @@ -65,5 +65,174 @@ ], "type": "object" }, - "name": "list_code_scanning_alerts" + "name": "list_code_scanning_alerts", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "dismissed_comment": { + "type": [ + "null", + "string" + ] + }, + "dismissed_reason": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "most_recent_instance": { + "additionalProperties": false, + "properties": { + "commit_sha": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "location": { + "additionalProperties": false, + "properties": { + "end_column": { + "type": [ + "null", + "integer" + ] + }, + "end_line": { + "type": [ + "null", + "integer" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "start_column": { + "type": [ + "null", + "integer" + ] + }, + "start_line": { + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "message": { + "type": [ + "null", + "string" + ] + }, + "ref": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "rule": { + "additionalProperties": false, + "properties": { + "description": { + "type": [ + "null", + "string" + ] + }, + "help": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "security_severity_level": { + "description": "Security impact level, distinct from rule diagnostic severity.", + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_commits.snap b/pkg/github/__toolsnaps__/list_commits.snap index bc4ffd1753..ddd55bac91 100644 --- a/pkg/github/__toolsnaps__/list_commits.snap +++ b/pkg/github/__toolsnaps__/list_commits.snap @@ -5,6 +5,18 @@ "title": "List commits" }, "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).", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "author": { @@ -27,7 +39,8 @@ }, "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "page": { "description": "Page number for pagination (min 1)", @@ -46,7 +59,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "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.", @@ -67,5 +81,268 @@ ], "type": "object" }, - "name": "list_commits" + "name": "list_commits", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "commit": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "committer": { + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + }, + "committer": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "sha": { + "type": [ + "null", + "string" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + } } \ 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 ff602b13e1..1a27f1297b 100644 --- a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap +++ b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap @@ -53,5 +53,219 @@ ], "type": "object" }, - "name": "list_dependabot_alerts" + "name": "list_dependabot_alerts", + "outputSchema": { + "additionalProperties": false, + "properties": { + "alerts": { + "items": { + "additionalProperties": false, + "properties": { + "dependency": { + "additionalProperties": false, + "properties": { + "manifest_path": { + "type": [ + "null", + "string" + ] + }, + "package": { + "additionalProperties": false, + "properties": { + "ecosystem": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "scope": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "dismissed_comment": { + "type": [ + "null", + "string" + ] + }, + "dismissed_reason": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "security_advisory": { + "additionalProperties": false, + "properties": { + "classification": { + "description": "GitHub advisory classification, such as malware or general.", + "type": [ + "null", + "string" + ] + }, + "cve_id": { + "type": [ + "null", + "string" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "ghsa_id": { + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "summary": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "security_vulnerability": { + "additionalProperties": false, + "properties": { + "first_patched_version": { + "description": "Earliest version fixing the affected package.", + "type": [ + "null", + "string" + ] + }, + "package": { + "additionalProperties": false, + "properties": { + "ecosystem": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "vulnerable_version_range": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + }, + "prevCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage" + ], + "type": "object" + } + }, + "required": [ + "alerts", + "pageInfo" + ], + "type": "object" + } } \ 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 095973dc8b..69e5923f38 100644 --- a/pkg/github/__toolsnaps__/list_discussion_categories.snap +++ b/pkg/github/__toolsnaps__/list_discussion_categories.snap @@ -21,5 +21,65 @@ ], "type": "object" }, - "name": "list_discussion_categories" + "name": "list_discussion_categories", + "outputSchema": { + "additionalProperties": false, + "properties": { + "categories": { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "endCursor": { + "type": "string" + }, + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "startCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage", + "startCursor", + "endCursor" + ], + "type": "object" + }, + "totalCount": { + "type": "integer" + } + }, + "required": [ + "categories", + "pageInfo", + "totalCount" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_discussions.snap b/pkg/github/__toolsnaps__/list_discussions.snap index 6a2afd7dc4..ccb5fee0ff 100644 --- a/pkg/github/__toolsnaps__/list_discussions.snap +++ b/pkg/github/__toolsnaps__/list_discussions.snap @@ -51,5 +51,100 @@ ], "type": "object" }, - "name": "list_discussions" + "name": "list_discussions", + "outputSchema": { + "additionalProperties": false, + "properties": { + "discussions": { + "items": { + "additionalProperties": false, + "properties": { + "author": { + "type": "string" + }, + "category": { + "type": "string" + }, + "created_at": { + "description": "Creation time (RFC3339).", + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last update time (RFC3339).", + "type": [ + "null", + "string" + ] + } + }, + "required": [ + "author", + "category" + ], + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "endCursor": { + "type": "string" + }, + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "startCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage", + "startCursor", + "endCursor" + ], + "type": "object" + }, + "totalCount": { + "type": "integer" + } + }, + "required": [ + "discussions", + "pageInfo", + "totalCount" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_gists.snap b/pkg/github/__toolsnaps__/list_gists.snap index 8f09ff3880..f4a7c4ea23 100644 --- a/pkg/github/__toolsnaps__/list_gists.snap +++ b/pkg/github/__toolsnaps__/list_gists.snap @@ -29,5 +29,103 @@ }, "type": "object" }, - "name": "list_gists" + "name": "list_gists", + "outputSchema": { + "items": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "comments": { + "type": "integer" + }, + "created_at": { + "description": "Creation time in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "description": { + "type": "string" + }, + "files": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "content": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "language": { + "type": "string" + }, + "raw_url": { + "type": "string" + }, + "size": { + "description": "File size in bytes.", + "type": "integer" + }, + "type": { + "description": "File MIME type, when available.", + "type": "string" + } + }, + "type": "object" + }, + "type": "object" + }, + "git_pull_url": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "string" + }, + "owner": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "public": { + "description": "True for a public gist; false for a secret, URL-accessible gist.", + "type": "boolean" + }, + "updated_at": { + "description": "Last update time in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "type": "object" + } + ] + }, + "type": [ + "array", + "null" + ] + } } \ 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 687248f6e7..e4bc8d8407 100644 --- a/pkg/github/__toolsnaps__/list_global_security_advisories.snap +++ b/pkg/github/__toolsnaps__/list_global_security_advisories.snap @@ -84,5 +84,174 @@ }, "type": "object" }, - "name": "list_global_security_advisories" + "name": "list_global_security_advisories", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "cve_id": { + "type": [ + "null", + "string" + ] + }, + "cwe_ids": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "ghsa_id": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "published_at": { + "description": "Advisory publication time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "source_code_location": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "summary": { + "type": [ + "null", + "string" + ] + }, + "type": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last advisory update time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "vulnerabilities": { + "items": { + "additionalProperties": false, + "properties": { + "first_patched_version": { + "description": "Earliest version fixing the affected package.", + "type": [ + "null", + "string" + ] + }, + "package": { + "additionalProperties": false, + "properties": { + "ecosystem": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "patched_versions": { + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "vulnerable_functions": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "vulnerable_version_range": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "withdrawn_at": { + "description": "Withdrawal time, when withdrawn (RFC 3339).", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issue_fields.snap b/pkg/github/__toolsnaps__/list_issue_fields.snap index df9ae47ba6..f9744d009e 100644 --- a/pkg/github/__toolsnaps__/list_issue_fields.snap +++ b/pkg/github/__toolsnaps__/list_issue_fields.snap @@ -21,5 +21,94 @@ ], "type": "object" }, - "name": "list_issue_fields" + "name": "list_issue_fields", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "description": "The field value type.", + "enum": [ + "TEXT", + "NUMBER", + "DATE", + "SINGLE_SELECT" + ], + "type": "string" + }, + "description": { + "description": "The field description, when available.", + "type": "string" + }, + "full_database_id": { + "description": "The database ID of the issue field, when available.", + "type": "integer" + }, + "id": { + "description": "The node ID of the issue field.", + "type": "string" + }, + "name": { + "description": "The field name.", + "type": "string" + }, + "options": { + "description": "Available values for single-select fields.", + "items": { + "additionalProperties": false, + "properties": { + "color": { + "description": "The option color.", + "type": "string" + }, + "description": { + "description": "The option description, when available.", + "type": "string" + }, + "id": { + "description": "The option node ID.", + "type": "string" + }, + "name": { + "description": "The option name.", + "type": "string" + }, + "priority": { + "description": "Display order of this option, when set.", + "type": [ + "null", + "integer" + ] + } + }, + "required": [ + "id", + "name", + "color" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "visibility": { + "description": "The visibility policy for the field.", + "type": "string" + } + }, + "required": [ + "id", + "name", + "data_type", + "visibility" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } } \ 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 c1ea3d515a..8f95717e09 100644 --- a/pkg/github/__toolsnaps__/list_issue_types.snap +++ b/pkg/github/__toolsnaps__/list_issue_types.snap @@ -21,5 +21,55 @@ ], "type": "object" }, - "name": "list_issue_types" + "name": "list_issue_types", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "description": "The issue type color, when set.", + "type": [ + "null", + "string" + ] + }, + "description": { + "description": "The issue type description, when available.", + "type": [ + "null", + "string" + ] + }, + "id": { + "description": "The issue type ID.", + "type": [ + "null", + "integer" + ] + }, + "is_enabled": { + "description": "Whether this issue type is enabled.", + "type": [ + "null", + "boolean" + ] + }, + "name": { + "description": "The issue type name.", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issues_output.snap b/pkg/github/__toolsnaps__/list_issues_output.snap new file mode 100644 index 0000000000..2e5c6ac545 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_issues_output.snap @@ -0,0 +1,236 @@ +{ + "inputSchema": null, + "name": "list_issues", + "outputSchema": { + "additionalProperties": false, + "properties": { + "issues": { + "items": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "comments": { + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "type": [ + "null", + "string" + ] + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "value": { + "type": "string" + }, + "values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "type": [ + "null", + "string" + ] + }, + "user": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "endCursor": { + "type": "string" + }, + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "startCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage" + ], + "type": "object" + }, + "totalCount": { + "type": "integer" + } + }, + "required": [ + "issues", + "totalCount", + "pageInfo" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_label.snap b/pkg/github/__toolsnaps__/list_label.snap index 37e45b15fb..f85cd9a77d 100644 --- a/pkg/github/__toolsnaps__/list_label.snap +++ b/pkg/github/__toolsnaps__/list_label.snap @@ -22,5 +22,46 @@ ], "type": "object" }, - "name": "list_label" + "name": "list_label", + "outputSchema": { + "additionalProperties": false, + "properties": { + "labels": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "description": "Hex RGB without leading #.", + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "color", + "description" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "totalCount": { + "description": "Total labels in the repository.", + "type": "integer" + } + }, + "required": [ + "labels", + "totalCount" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_notifications.snap b/pkg/github/__toolsnaps__/list_notifications.snap index bb75d60427..7d768117e4 100644 --- a/pkg/github/__toolsnaps__/list_notifications.snap +++ b/pkg/github/__toolsnaps__/list_notifications.snap @@ -46,5 +46,121 @@ }, "type": "object" }, - "name": "list_notifications" + "name": "list_notifications", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "type": [ + "null", + "string" + ] + }, + "last_read_at": { + "description": "Last acknowledgement time (RFC3339).", + "type": [ + "null", + "string" + ] + }, + "reason": { + "description": "Notification reason reported by GitHub; new provider values are preserved.", + "type": [ + "null", + "string" + ] + }, + "repository": { + "additionalProperties": false, + "properties": { + "full_name": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "private": { + "type": [ + "null", + "boolean" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "subject": { + "additionalProperties": false, + "properties": { + "title": { + "type": [ + "null", + "string" + ] + }, + "type": { + "description": "Subject type reported by GitHub; new provider values are preserved.", + "type": [ + "null", + "string" + ] + }, + "url": { + "description": "API reference to the underlying notification subject.", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "unread": { + "type": [ + "null", + "boolean" + ] + }, + "updated_at": { + "description": "Last update time (RFC3339).", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "object", + "null" + ] + }, + "type": [ + "null", + "array" + ] + } } \ 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 3646d86c34..499d4cd405 100644 --- a/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap +++ b/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap @@ -44,5 +44,162 @@ ], "type": "object" }, - "name": "list_org_repository_security_advisories" + "name": "list_org_repository_security_advisories", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "cve_id": { + "type": [ + "null", + "string" + ] + }, + "cwe_ids": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "ghsa_id": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "published_at": { + "description": "Advisory publication time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "summary": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last advisory update time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "vulnerabilities": { + "items": { + "additionalProperties": false, + "properties": { + "first_patched_version": { + "description": "Earliest version fixing the affected package.", + "type": [ + "null", + "string" + ] + }, + "package": { + "additionalProperties": false, + "properties": { + "ecosystem": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "patched_versions": { + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "vulnerable_functions": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "vulnerable_version_range": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "withdrawn_at": { + "description": "Withdrawal time, when withdrawn (RFC 3339).", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_pull_requests_output.snap b/pkg/github/__toolsnaps__/list_pull_requests_output.snap new file mode 100644 index 0000000000..2d70947044 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_pull_requests_output.snap @@ -0,0 +1,335 @@ +{ + "inputSchema": null, + "name": "list_pull_requests", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "additions": { + "description": "Number of lines added.", + "type": [ + "null", + "integer" + ] + }, + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "base": { + "additionalProperties": false, + "properties": { + "ref": { + "type": "string" + }, + "repo": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "full_name": { + "type": "string" + } + }, + "required": [ + "full_name" + ], + "type": [ + "null", + "object" + ] + }, + "sha": { + "type": "string" + } + }, + "required": [ + "ref", + "sha" + ], + "type": [ + "null", + "object" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "changed_files": { + "description": "Number of files changed.", + "type": [ + "null", + "integer" + ] + }, + "closed_at": { + "description": "Closing time in RFC 3339 format.", + "type": [ + "null", + "string" + ] + }, + "comments": { + "description": "Number of comments on the pull request.", + "type": [ + "null", + "integer" + ] + }, + "commits": { + "description": "Number of commits in the pull request.", + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "description": "Creation time in RFC 3339 format.", + "type": [ + "null", + "string" + ] + }, + "deletions": { + "description": "Number of lines removed.", + "type": [ + "null", + "integer" + ] + }, + "draft": { + "type": [ + "null", + "boolean" + ] + }, + "head": { + "additionalProperties": false, + "properties": { + "ref": { + "type": "string" + }, + "repo": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "full_name": { + "type": "string" + } + }, + "required": [ + "full_name" + ], + "type": [ + "null", + "object" + ] + }, + "sha": { + "type": "string" + } + }, + "required": [ + "ref", + "sha" + ], + "type": [ + "null", + "object" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "mergeable_state": { + "type": [ + "null", + "string" + ] + }, + "merged": { + "type": [ + "null", + "boolean" + ] + }, + "merged_at": { + "description": "Merge time in RFC 3339 format.", + "type": [ + "null", + "string" + ] + }, + "merged_by": { + "type": [ + "null", + "string" + ] + }, + "milestone": { + "type": [ + "null", + "string" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "requested_reviewers": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "state": { + "description": "Pull request state: open or closed.", + "type": [ + "null", + "string" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last update time in RFC 3339 format.", + "type": [ + "null", + "string" + ] + }, + "user": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_releases.snap b/pkg/github/__toolsnaps__/list_releases.snap index 4eeef279e9..1e6e15e5c0 100644 --- a/pkg/github/__toolsnaps__/list_releases.snap +++ b/pkg/github/__toolsnaps__/list_releases.snap @@ -5,6 +5,18 @@ "title": "List releases" }, "description": "List releases in a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "fields": { @@ -27,7 +39,8 @@ }, "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "page": { "description": "Page number for pagination (min 1)", @@ -42,7 +55,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -51,5 +65,162 @@ ], "type": "object" }, - "name": "list_releases" + "name": "list_releases", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "draft": { + "description": "Whether the release is unpublished.", + "type": [ + "null", + "boolean" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "prerelease": { + "description": "Whether the release is a prerelease.", + "type": [ + "null", + "boolean" + ] + }, + "published_at": { + "description": "Publication time in RFC3339 format.", + "type": [ + "null", + "string" + ] + }, + "tag_name": { + "type": [ + "null", + "string" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_repository_collaborators.snap b/pkg/github/__toolsnaps__/list_repository_collaborators.snap index 5af768fe05..dc1bce66fb 100644 --- a/pkg/github/__toolsnaps__/list_repository_collaborators.snap +++ b/pkg/github/__toolsnaps__/list_repository_collaborators.snap @@ -5,6 +5,18 @@ "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.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "affiliation": { @@ -18,7 +30,8 @@ }, "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "page": { "description": "Page number for pagination (default 1, min 1)", @@ -33,7 +46,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -42,5 +56,59 @@ ], "type": "object" }, - "name": "list_repository_collaborators" + "name": "list_repository_collaborators", + "outputSchema": { + "additionalProperties": false, + "properties": { + "firstPage": { + "type": "integer" + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "role_name": { + "type": "string" + } + }, + "required": [ + "login", + "id", + "role_name" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "lastPage": { + "type": "integer" + }, + "nextPage": { + "type": "integer" + }, + "prevPage": { + "type": "integer" + } + }, + "required": [ + "firstPage", + "items", + "lastPage", + "nextPage", + "prevPage" + ], + "type": [ + "null", + "object" + ] + } } \ 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 50ec8db699..15038088e4 100644 --- a/pkg/github/__toolsnaps__/list_repository_security_advisories.snap +++ b/pkg/github/__toolsnaps__/list_repository_security_advisories.snap @@ -49,5 +49,162 @@ ], "type": "object" }, - "name": "list_repository_security_advisories" + "name": "list_repository_security_advisories", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "cve_id": { + "type": [ + "null", + "string" + ] + }, + "cwe_ids": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "ghsa_id": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "published_at": { + "description": "Advisory publication time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "summary": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last advisory update time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "vulnerabilities": { + "items": { + "additionalProperties": false, + "properties": { + "first_patched_version": { + "description": "Earliest version fixing the affected package.", + "type": [ + "null", + "string" + ] + }, + "package": { + "additionalProperties": false, + "properties": { + "ecosystem": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "patched_versions": { + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "vulnerable_functions": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "vulnerable_version_range": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "withdrawn_at": { + "description": "Withdrawal time, when withdrawn (RFC 3339).", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } } \ 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 66aced9eac..2bceafa03d 100644 --- a/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap +++ b/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap @@ -57,5 +57,141 @@ ], "type": "object" }, - "name": "list_secret_scanning_alerts" + "name": "list_secret_scanning_alerts", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "first_location_detected": { + "additionalProperties": false, + "properties": { + "commit_sha": { + "type": [ + "null", + "string" + ] + }, + "end_column": { + "type": [ + "null", + "integer" + ] + }, + "end_line": { + "type": [ + "null", + "integer" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "start_column": { + "type": [ + "null", + "integer" + ] + }, + "start_line": { + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "has_more_locations": { + "description": "Whether additional secret locations exist beyond the first location.", + "type": [ + "null", + "boolean" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "is_base64_encoded": { + "description": "Whether the detected secret is base64 encoded.", + "type": [ + "null", + "boolean" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "push_protection_bypassed": { + "description": "Whether push protection was bypassed for this secret.", + "type": [ + "null", + "boolean" + ] + }, + "resolution": { + "type": [ + "null", + "string" + ] + }, + "resolution_comment": { + "type": [ + "null", + "string" + ] + }, + "secret": { + "type": [ + "null", + "string" + ] + }, + "secret_type": { + "type": [ + "null", + "string" + ] + }, + "secret_type_display_name": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "validity": { + "description": "Credential validity reported by GitHub: active, inactive, or unknown.", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } } \ 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 f4c37373e5..d6beacf97c 100644 --- a/pkg/github/__toolsnaps__/list_starred_repositories.snap +++ b/pkg/github/__toolsnaps__/list_starred_repositories.snap @@ -5,6 +5,18 @@ "title": "List starred repositories" }, "description": "List starred repositories", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACxklEQVRIidWTT2gUdxTHP29mjMnBgkqNEQsbd3ZnfrvLzEoOnoRc217roYdSEQTRS4sFQbGHgK0UqigoKtKq7a29itdAT540IYmZ3cR/bYVAiVAvMZnfvB52NVtcdyd48sHAj3nf9/3zfjMuGyjf1L/a/uGOfcv/LN3NOyO5yf1oN5tkARCLU3704N6TPHNOXgEG5MQrQ65k3+Qdy5XAmLGRNdJFEX5RVQH5wrH4jcbU3/1mcyVYxZ4APMfqWYt7BnAyV3Ol6CswWqsNC3oYuJUk04/au/8V5EgQ7N31TgKFQmHQte5pYADXfv+64drvAM862clCoTDYi0MAwrBesEJV0TJISclKgpSAjwBH0JvNB9MHOwdLJrqhyJdABvypaFNwmqBNQRquMjs/f/+xBEE0ah1ZZP3Cl4Em0BRoZGjTy17eTpLkRadAEARbUmfzpw5SUigDpfazrQ1RizMKIL6Jrvkm1mIYX+kVN08Vw/iKb2L1TXStwzRSDOMLrUb9Mhv4ATtKfBOf6zD6xv2+BpQq8dVugF7k6wajSz0NFk080QLG13OKiG+iS76JtWiiH3LZWRepH+2H9U39WIs8nujW7+rQy/Tn1ilb7SegyirAJpWfcgtYodY+zvQTEGQGwKK1bv237FiqgLI2NNf51pixEWPGRv4HTQdmAVXYiAA1hScLC3f/BQjDcLtv4rNrpItrpI9LlfhqoVrdCdDGPEWodiPyugegCsz6/r4PGFg5nipfA0PATVp2D3nqfe5X4vOsDv6orMzIWxK88b2Oj497fy09fwE8BB0G2Qb85mb6bZJMJwBBEAXWkQngAOgyyBKwZ/fw1i2Tk5NpT4E9YVx2hKTdvoPoqYW5qXvd3PmVeC8qZ0A/BsiU4OH8VKPniobc9NmK9S66jv7emJv6oxvxq2oLf1KuRPttJp8NuemzXvj3s/4Daoz4w62BFyQAAAAASUVORK5CYII=", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABsklEQVRIidWUOy9EURSF1xaFAgWFd5iIUZjGD/AL/AhqCgqJelSi8EgQZLxKPf6GxihMJB6tKEa8k/kUsy83g5lzQ2MnJ/dm77XXWudknyMlCGAamErSk4S8G3gGXoDe0L66BBqzksz/ZxK5qxVAB/AIbAJbwBPQ9ZcCy8ArkAJ6/ZiW/oq8DXgAcrHctu+i87fkDcAq8Ab0x/L9nlsFGqpxmDf0SRqSlJY0EFs9Kg/CvpmNV4jvSRqTVJJ0I6kQW+eS8mZ2aUBK0oU+J+SuAliQdGRm9xUCTZJG3UjcWEsEkZQSYD4ZABs1z61GABvOtQVYlDRgxQvrH4VkxAYsRkaBumqAzS+A2uSRwbWqBoE5B+ZCRJx8zXsWQh1FIhMB2EnHzn1X/8nhrn9fA/xEmJ0kAhn/ngYIRJjMd8WfBIZUnuOzeNIfvY4KbN6xiQQykq7MrOjErcC8yhfy0qesXZIcc+2mwgI4AQ6BZiALFP3tyfl681zWMYfASSh5vb+UeeAWKAEHwGAMM+i5kmPy3lMfIpDmM46B4SrYYcdEkQ4RaPRbORK05XLPiPc0hvb8n3gHRCXiyIC2CgcAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "direction": { @@ -41,5 +53,83 @@ }, "type": "object" }, - "name": "list_starred_repositories" + "name": "list_starred_repositories", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "archived": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "default_branch": { + "type": "string" + }, + "description": { + "type": "string" + }, + "fork": { + "type": "boolean" + }, + "forks_count": { + "type": "integer" + }, + "full_name": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "language": { + "type": "string" + }, + "name": { + "type": "string" + }, + "open_issues_count": { + "type": "integer" + }, + "private": { + "type": "boolean" + }, + "stargazers_count": { + "type": "integer" + }, + "topics": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "full_name", + "html_url", + "stargazers_count", + "forks_count", + "open_issues_count", + "private", + "fork", + "archived" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_tags_output.snap b/pkg/github/__toolsnaps__/list_tags_output.snap new file mode 100644 index 0000000000..2975b18ad3 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_tags_output.snap @@ -0,0 +1,26 @@ +{ + "inputSchema": null, + "name": "list_tags", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "sha": { + "type": "string" + } + }, + "required": [ + "name", + "sha" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } +} \ 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 e062f2d25d..644090c8f1 100644 --- a/pkg/github/__toolsnaps__/manage_notification_subscription.snap +++ b/pkg/github/__toolsnaps__/manage_notification_subscription.snap @@ -28,5 +28,62 @@ ], "type": "object" }, - "name": "manage_notification_subscription" + "name": "manage_notification_subscription", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "subscription": { + "additionalProperties": false, + "properties": { + "created_at": { + "description": "Subscription creation time (RFC3339).", + "type": [ + "null", + "string" + ] + }, + "ignored": { + "type": [ + "null", + "boolean" + ] + }, + "reason": { + "type": [ + "null", + "string" + ] + }, + "subscribed": { + "type": [ + "null", + "boolean" + ] + } + }, + "type": "object" + } + }, + "required": [ + "subscription" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + ], + "type": "object" + } } \ 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 7f4464860d..7dd35c43a0 100644 --- a/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap +++ b/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap @@ -33,5 +33,62 @@ ], "type": "object" }, - "name": "manage_repository_notification_subscription" + "name": "manage_repository_notification_subscription", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "subscription": { + "additionalProperties": false, + "properties": { + "created_at": { + "description": "Subscription creation time (RFC3339).", + "type": [ + "null", + "string" + ] + }, + "ignored": { + "type": [ + "null", + "boolean" + ] + }, + "reason": { + "type": [ + "null", + "string" + ] + }, + "subscribed": { + "type": [ + "null", + "boolean" + ] + } + }, + "type": "object" + } + }, + "required": [ + "subscription" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + ], + "type": "object" + } } \ 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 42de313e4a..2f0fae03a4 100644 --- a/pkg/github/__toolsnaps__/mark_all_notifications_read.snap +++ b/pkg/github/__toolsnaps__/mark_all_notifications_read.snap @@ -22,5 +22,17 @@ }, "type": "object" }, - "name": "mark_all_notifications_read" + "name": "mark_all_notifications_read", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } } \ 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 4a5bdfd8b3..4f570b1b12 100644 --- a/pkg/github/__toolsnaps__/merge_pull_request.snap +++ b/pkg/github/__toolsnaps__/merge_pull_request.snap @@ -27,6 +27,10 @@ "description": "Title for merge commit", "type": "string" }, + "expectedHeadSha": { + "description": "The expected SHA of the pull request's HEAD ref", + "type": "string" + }, "merge_method": { "description": "Merge method", "enum": [ @@ -56,5 +60,35 @@ ], "type": "object" }, - "name": "merge_pull_request" + "name": "merge_pull_request", + "outputSchema": { + "additionalProperties": false, + "properties": { + "merged": { + "description": "Whether the pull request was merged.", + "type": [ + "null", + "boolean" + ] + }, + "message": { + "description": "Operation result message.", + "type": [ + "null", + "string" + ] + }, + "sha": { + "description": "Git commit SHA.", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "object", + "null" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap index 1380e84d5d..1996d199ce 100644 --- a/pkg/github/__toolsnaps__/projects_get.snap +++ b/pkg/github/__toolsnaps__/projects_get.snap @@ -70,5 +70,473 @@ ], "type": "object" }, - "name": "projects_get" + "name": "projects_get", + "outputSchema": { + "additionalProperties": false, + "properties": { + "field": { + "additionalProperties": false, + "properties": { + "configuration": { + "additionalProperties": false, + "properties": { + "duration": { + "description": "Iteration duration in days.", + "type": "integer" + }, + "iterations": { + "items": { + "additionalProperties": false, + "properties": { + "duration": { + "description": "Iteration duration in days.", + "type": "integer" + }, + "id": { + "type": "string" + }, + "start_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + }, + "title": { + "additionalProperties": false, + "properties": { + "html": { + "type": "string" + }, + "raw": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "start_day": { + "type": "integer" + } + }, + "type": "object" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + }, + "data_type": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "options": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "type": "string" + }, + "description": { + "additionalProperties": false, + "properties": { + "html": { + "type": "string" + }, + "raw": { + "type": "string" + } + }, + "type": "object" + }, + "id": { + "type": "string" + }, + "name": { + "additionalProperties": false, + "properties": { + "html": { + "type": "string" + }, + "raw": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + } + }, + "type": "object" + }, + "item": { + "additionalProperties": false, + "properties": { + "archived_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "content": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": "array" + }, + "author": { + "type": "string" + }, + "closed_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "comments": { + "type": "integer" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "draft": { + "type": "boolean" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "merged": { + "type": "boolean" + }, + "merged_at": { + "type": "string" + }, + "milestone": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "state_reason": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "type": "object" + }, + "content_type": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "creator": { + "type": "string" + }, + "fields": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "value": { + "description": "Recursively compacted JSON field value; unknown field types retain arbitrary object keys." + } + }, + "type": "object" + }, + "type": "array" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "method": { + "enum": [ + "get_project", + "get_project_field", + "get_project_item", + "get_project_status_update", + "get_project_view" + ], + "type": "string" + }, + "project": { + "additionalProperties": false, + "properties": { + "closed_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + }, + "creator": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "deleted_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + }, + "deleted_by": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "owner": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "owner_type": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "short_description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + } + }, + "type": "object" + }, + "status_update": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "creator": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "start_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + }, + "status": { + "enum": [ + "INACTIVE", + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE" + ], + "type": "string" + }, + "target_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "view": { + "additionalProperties": false, + "properties": { + "filter": { + "type": "string" + }, + "id": { + "type": "string" + }, + "layout": { + "enum": [ + "board", + "table", + "roadmap" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "visible_fields": { + "items": { + "type": "integer" + }, + "type": "array" + } + }, + "required": [ + "id", + "number", + "name", + "layout", + "filter", + "visible_fields" + ], + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_list.snap b/pkg/github/__toolsnaps__/projects_list.snap index 487119f04a..49f741369a 100644 --- a/pkg/github/__toolsnaps__/projects_list.snap +++ b/pkg/github/__toolsnaps__/projects_list.snap @@ -52,7 +52,7 @@ ], "type": "string" }, - "per_page": { + "perPage": { "description": "Results per page (max 50)", "type": "number" }, @@ -71,5 +71,669 @@ ], "type": "object" }, - "name": "projects_list" + "name": "projects_list", + "outputSchema": { + "additionalProperties": false, + "properties": { + "fields": { + "additionalProperties": false, + "properties": { + "fields": { + "items": { + "additionalProperties": false, + "properties": { + "configuration": { + "additionalProperties": false, + "properties": { + "duration": { + "description": "Iteration duration in days.", + "type": "integer" + }, + "iterations": { + "items": { + "additionalProperties": false, + "properties": { + "duration": { + "description": "Iteration duration in days.", + "type": "integer" + }, + "id": { + "type": "string" + }, + "start_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + }, + "title": { + "additionalProperties": false, + "properties": { + "html": { + "type": "string" + }, + "raw": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "start_day": { + "type": "integer" + } + }, + "type": "object" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + }, + "data_type": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "options": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "type": "string" + }, + "description": { + "additionalProperties": false, + "properties": { + "html": { + "type": "string" + }, + "raw": { + "type": "string" + } + }, + "type": "object" + }, + "id": { + "type": "string" + }, + "name": { + "additionalProperties": false, + "properties": { + "html": { + "type": "string" + }, + "raw": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + }, + "prevCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage" + ], + "type": "object" + } + }, + "required": [ + "fields", + "pageInfo" + ], + "type": "object" + }, + "items": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "additionalProperties": false, + "properties": { + "archived_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "content": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": "array" + }, + "author": { + "type": "string" + }, + "closed_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "comments": { + "type": "integer" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "draft": { + "type": "boolean" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "merged": { + "type": "boolean" + }, + "merged_at": { + "type": "string" + }, + "milestone": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "state_reason": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "type": "object" + }, + "content_type": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "creator": { + "type": "string" + }, + "fields": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "value": { + "description": "Recursively compacted JSON field value; unknown field types retain arbitrary object keys." + } + }, + "type": "object" + }, + "type": "array" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + }, + "prevCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage" + ], + "type": "object" + } + }, + "required": [ + "items", + "pageInfo" + ], + "type": "object" + }, + "method": { + "enum": [ + "list_projects", + "list_project_fields", + "list_project_items", + "list_project_status_updates", + "list_project_views" + ], + "type": "string" + }, + "projects": { + "additionalProperties": false, + "properties": { + "note": { + "type": "string" + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + }, + "prevCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage" + ], + "type": "object" + }, + "projects": { + "items": { + "additionalProperties": false, + "properties": { + "closed_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + }, + "creator": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "deleted_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + }, + "deleted_by": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "owner": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "owner_type": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "short_description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "projects" + ], + "type": "object" + }, + "status_updates": { + "additionalProperties": false, + "properties": { + "pageInfo": { + "additionalProperties": false, + "properties": { + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + }, + "prevCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage", + "nextCursor", + "prevCursor" + ], + "type": "object" + }, + "statusUpdates": { + "items": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "creator": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "start_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + }, + "status": { + "enum": [ + "INACTIVE", + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE" + ], + "type": "string" + }, + "target_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "statusUpdates", + "pageInfo" + ], + "type": "object" + }, + "views": { + "additionalProperties": false, + "properties": { + "pageInfo": { + "additionalProperties": false, + "properties": { + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + }, + "prevCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage", + "nextCursor", + "prevCursor" + ], + "type": "object" + }, + "views": { + "items": { + "additionalProperties": false, + "properties": { + "filter": { + "type": "string" + }, + "id": { + "type": "string" + }, + "layout": { + "enum": [ + "board", + "table", + "roadmap" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "visible_fields": { + "items": { + "type": "integer" + }, + "type": "array" + } + }, + "required": [ + "id", + "number", + "name", + "layout", + "filter", + "visible_fields" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "views", + "pageInfo" + ], + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index 0ea38c2b0a..9644750bf1 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -277,5 +277,509 @@ ], "type": "object" }, - "name": "projects_write" + "name": "projects_write", + "outputSchema": { + "additionalProperties": false, + "properties": { + "added": { + "additionalProperties": false, + "properties": { + "full_database_id": { + "type": "string" + }, + "id": { + "type": [ + "null", + "string" + ] + }, + "item_id": { + "type": "integer" + }, + "message": { + "type": "string" + } + }, + "required": [ + "id", + "message" + ], + "type": "object" + }, + "batch": { + "additionalProperties": false, + "properties": { + "failed": { + "type": "integer" + }, + "results": { + "items": { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "candidates": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "code": { + "type": "string" + }, + "hint": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "index": { + "type": "integer" + }, + "item": { + "additionalProperties": false, + "properties": { + "full_database_id": { + "type": "string" + }, + "item_id": { + "type": "integer" + }, + "node_id": { + "type": "string" + } + }, + "type": "object" + }, + "ref": { + "additionalProperties": true, + "description": "Original batch reference, including malformed or extra fields.", + "type": "object" + }, + "status": { + "enum": [ + "succeeded", + "failed", + "unknown" + ], + "type": "string" + } + }, + "required": [ + "index", + "status" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "succeeded": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + }, + "required": [ + "total", + "succeeded", + "failed", + "unknown", + "results" + ], + "type": "object" + }, + "deleted_item": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "deleted_view": { + "additionalProperties": false, + "properties": { + "deleted_view_id": { + "type": "string" + } + }, + "required": [ + "deleted_view_id" + ], + "type": "object" + }, + "issue_fields": { + "additionalProperties": false, + "properties": { + "html_url": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "html_url" + ], + "type": "object" + }, + "item": { + "additionalProperties": false, + "properties": { + "archived_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "content": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": "array" + }, + "author": { + "type": "string" + }, + "closed_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "comments": { + "type": "integer" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "draft": { + "type": "boolean" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "merged": { + "type": "boolean" + }, + "merged_at": { + "type": "string" + }, + "milestone": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "state_reason": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "type": "object" + }, + "content_type": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "creator": { + "type": "string" + }, + "fields": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "value": { + "description": "Recursively compacted JSON field value; unknown field types retain arbitrary object keys." + } + }, + "type": "object" + }, + "type": "array" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "iteration_field": { + "additionalProperties": false, + "properties": { + "configuration": { + "additionalProperties": false, + "properties": { + "iterations": { + "items": { + "additionalProperties": false, + "properties": { + "duration": { + "description": "Iteration duration in days.", + "type": "integer" + }, + "id": { + "type": "string" + }, + "start_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "id", + "title", + "start_date", + "duration" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "iterations" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "configuration" + ], + "type": "object" + }, + "method": { + "enum": [ + "add_project_item", + "update_project_item", + "update_project_items", + "delete_project_item", + "create_project_status_update", + "create_project_view", + "update_project_view", + "delete_project_view", + "create_project", + "create_iteration_field" + ], + "type": "string" + }, + "project": { + "additionalProperties": false, + "properties": { + "html_url": { + "type": "string" + }, + "id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "title": { + "type": "string" + } + }, + "required": [ + "id", + "number", + "title", + "html_url" + ], + "type": "object" + }, + "status_update": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "creator": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "start_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + }, + "status": { + "enum": [ + "INACTIVE", + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE" + ], + "type": "string" + }, + "target_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "view": { + "additionalProperties": false, + "properties": { + "filter": { + "type": "string" + }, + "id": { + "type": "string" + }, + "layout": { + "enum": [ + "board", + "table", + "roadmap" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "visible_fields": { + "items": { + "type": "integer" + }, + "type": "array" + } + }, + "required": [ + "id", + "number", + "name", + "layout", + "filter", + "visible_fields" + ], + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + } } \ 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 41bc90b597..c99d4276e2 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -12,7 +12,7 @@ "type": "string" }, "method": { - "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get combined commit status of a head commit in a pull request.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned.\n 6. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 7. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned.\n 8. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n 9. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", + "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get combined commit status of a head commit in a pull request.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned.\n 6. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns thread metadata and comments with nullable current and original line-range coordinates (line, start_line, original_line, original_start_line). Current coordinates are omitted when unavailable, such as for outdated comments. Use cursor-based pagination (perPage, after) to control results.\n 7. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned.\n 8. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n 9. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", "enum": [ "get", "get_diff", @@ -58,5 +58,945 @@ ], "type": "object" }, - "name": "pull_request_read" + "name": "pull_request_read", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "additions": { + "description": "Number of lines.", + "type": "integer" + }, + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "base": { + "additionalProperties": false, + "properties": { + "ref": { + "type": "string" + }, + "repo": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "full_name": { + "type": "string" + } + }, + "required": [ + "full_name" + ], + "type": [ + "null", + "object" + ] + }, + "sha": { + "description": "Git commit SHA.", + "type": "string" + } + }, + "required": [ + "ref", + "sha" + ], + "type": [ + "null", + "object" + ] + }, + "body": { + "type": "string" + }, + "changed_files": { + "type": "integer" + }, + "closed_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "comments": { + "type": "integer" + }, + "commits": { + "type": "integer" + }, + "created_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "deletions": { + "description": "Number of lines.", + "type": "integer" + }, + "draft": { + "type": "boolean" + }, + "head": { + "additionalProperties": false, + "properties": { + "ref": { + "type": "string" + }, + "repo": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "full_name": { + "type": "string" + } + }, + "required": [ + "full_name" + ], + "type": [ + "null", + "object" + ] + }, + "sha": { + "description": "Git commit SHA.", + "type": "string" + } + }, + "required": [ + "ref", + "sha" + ], + "type": [ + "null", + "object" + ] + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "mergeable_state": { + "type": "string" + }, + "merged": { + "description": "Whether the pull request was merged.", + "type": "boolean" + }, + "merged_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "merged_by": { + "type": "string" + }, + "milestone": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "requested_reviewers": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "state": { + "description": "Pull request state; empty for a sparse response.", + "enum": [ + "open", + "closed", + "" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "number", + "title", + "state", + "draft", + "merged", + "html_url" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "diff": { + "description": "Unified pull request diff.", + "type": "string" + } + }, + "required": [ + "diff" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "sha": { + "description": "Git commit SHA.", + "type": "string" + }, + "state": { + "description": "Commit status; empty for a sparse response.", + "enum": [ + "error", + "failure", + "pending", + "success", + "" + ], + "type": "string" + }, + "statuses": { + "items": { + "additionalProperties": false, + "properties": { + "context": { + "type": "string" + }, + "created_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "description": { + "type": "string" + }, + "state": { + "description": "Commit status; empty for a sparse response.", + "enum": [ + "error", + "failure", + "pending", + "success", + "" + ], + "type": "string" + }, + "target_url": { + "type": "string" + }, + "updated_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + } + }, + "required": [ + "state", + "context" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "description": "Total number of results.", + "type": "integer" + } + }, + "required": [ + "state", + "sha", + "total_count", + "statuses" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "pageInfo": { + "additionalProperties": false, + "properties": { + "endCursor": { + "type": "string" + }, + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "startCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage" + ], + "type": "object" + }, + "review_threads": { + "items": { + "additionalProperties": false, + "properties": { + "comments": { + "items": { + "additionalProperties": false, + "properties": { + "author": { + "type": "string" + }, + "body": { + "type": "string" + }, + "created_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "line": { + "description": "One-based diff line number.", + "type": [ + "null", + "integer" + ] + }, + "original_line": { + "description": "One-based diff line number.", + "type": [ + "null", + "integer" + ] + }, + "original_start_line": { + "description": "One-based diff line number.", + "type": [ + "null", + "integer" + ] + }, + "path": { + "type": "string" + }, + "start_line": { + "description": "One-based diff line number.", + "type": [ + "null", + "integer" + ] + }, + "updated_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + } + }, + "required": [ + "path", + "html_url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "id": { + "type": "string" + }, + "is_collapsed": { + "type": "boolean" + }, + "is_outdated": { + "type": "boolean" + }, + "is_resolved": { + "type": "boolean" + }, + "total_count": { + "description": "Total number of results.", + "type": "integer" + } + }, + "required": [ + "id", + "is_resolved", + "is_outdated", + "is_collapsed", + "comments", + "total_count" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "totalCount": { + "description": "Total number of results.", + "type": "integer" + } + }, + "required": [ + "review_threads", + "totalCount", + "pageInfo" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "check_runs": { + "items": { + "additionalProperties": false, + "properties": { + "completed_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "conclusion": { + "description": "Completed check outcome.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "details_url": { + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "started_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "status": { + "description": "Check execution status; empty for a sparse response.", + "enum": [ + "queued", + "in_progress", + "completed", + "waiting", + "requested", + "pending", + "" + ], + "type": "string" + } + }, + "required": [ + "id", + "name", + "status" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "description": "Total number of results.", + "type": "integer" + } + }, + "required": [ + "total_count", + "check_runs" + ], + "type": "object" + }, + { + "anyOf": [ + { + "items": { + "additionalProperties": false, + "properties": { + "additions": { + "description": "Number of lines.", + "type": "integer" + }, + "changes": { + "description": "Number of lines.", + "type": "integer" + }, + "deletions": { + "description": "Number of lines.", + "type": "integer" + }, + "filename": { + "type": "string" + }, + "patch": { + "type": "string" + }, + "previous_filename": { + "type": "string" + }, + "status": { + "description": "File change status.", + "enum": [ + "added", + "removed", + "modified", + "renamed", + "copied", + "changed", + "unchanged" + ], + "type": "string" + } + }, + "required": [ + "filename" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + { + "items": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "message": { + "type": "string" + }, + "sha": { + "description": "Git commit SHA.", + "type": "string" + } + }, + "required": [ + "sha" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + { + "items": { + "additionalProperties": false, + "properties": { + "author_association": { + "type": "string" + }, + "body": { + "type": "string" + }, + "commit_id": { + "description": "Git commit SHA.", + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "state": { + "description": "Review state; empty for a sparse response.", + "enum": [ + "APPROVED", + "CHANGES_REQUESTED", + "COMMENTED", + "DISMISSED", + "PENDING", + "" + ], + "type": "string" + }, + "submitted_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "id", + "state", + "html_url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + { + "items": { + "additionalProperties": false, + "properties": { + "author_association": { + "type": "string" + }, + "body": { + "type": "string" + }, + "created_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "reactions": { + "additionalProperties": false, + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "description": "Total number of results.", + "type": "integer" + } + }, + "required": [ + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": [ + "null", + "object" + ] + }, + "updated_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "id", + "html_url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + ], + "type": "array" + } + ] + } } \ 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 74ef808559..70cf6471c1 100644 --- a/pkg/github/__toolsnaps__/pull_request_review_write.snap +++ b/pkg/github/__toolsnaps__/pull_request_review_write.snap @@ -60,5 +60,18 @@ ], "type": "object" }, - "name": "pull_request_review_write" + "name": "pull_request_review_write", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Operation result message.", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/pull_request_review_write_resolution_reason.snap b/pkg/github/__toolsnaps__/pull_request_review_write_resolution_reason.snap new file mode 100644 index 0000000000..45e19ca7bf --- /dev/null +++ b/pkg/github/__toolsnaps__/pull_request_review_write_resolution_reason.snap @@ -0,0 +1,81 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": false, + "title": "Write operations (create, submit, delete) on pull request reviews" + }, + "description": "Create and/or submit, delete review of a pull request.\n\nAvailable methods:\n- create: Create a new review of a pull request. If \"event\" parameter is provided, the review is submitted. If \"event\" is omitted, a pending review is created.\n- submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The \"body\" and \"event\" parameters are used when submitting the review.\n- delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request.\n- resolve_thread: Resolve a review thread. Requires only \"threadId\" parameter with the thread's node ID (e.g., PRRT_kwDOxxx). The owner, repo, and pullNumber parameters are not used for this method. Resolving an already-resolved thread is a no-op.\n- unresolve_thread: Unresolve a previously resolved review thread. Requires only \"threadId\" parameter. The owner, repo, and pullNumber parameters are not used for this method. Unresolving an already-unresolved thread is a no-op.\n", + "inputSchema": { + "properties": { + "body": { + "description": "Review comment text", + "type": "string" + }, + "commitID": { + "description": "SHA of commit to review", + "type": "string" + }, + "event": { + "description": "Review action to perform.", + "enum": [ + "APPROVE", + "REQUEST_CHANGES", + "COMMENT" + ], + "type": "string" + }, + "method": { + "description": "The write operation to perform on pull request review.", + "enum": [ + "create", + "submit_pending", + "delete_pending", + "resolve_thread", + "unresolve_thread" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "pullNumber": { + "description": "Pull request number", + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "resolutionReason": { + "description": "Optional reason for resolving a Copilot code review thread: addressed, wont-fix, or invalid.", + "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", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Operation result message.", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/push_files.snap b/pkg/github/__toolsnaps__/push_files.snap index 798ad18451..ace5e477e5 100644 --- a/pkg/github/__toolsnaps__/push_files.snap +++ b/pkg/github/__toolsnaps__/push_files.snap @@ -5,6 +5,18 @@ "title": "Push files to repository" }, "description": "Push multiple files to a GitHub repository in a single commit", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "branch": { @@ -39,11 +51,13 @@ }, "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -55,5 +69,39 @@ ], "type": "object" }, - "name": "push_files" + "name": "push_files", + "outputSchema": { + "additionalProperties": false, + "properties": { + "object": { + "additionalProperties": false, + "properties": { + "sha": { + "type": "string" + }, + "type": { + "description": "Git object kind: commit, tag, tree, or blob.", + "type": "string" + } + }, + "required": [ + "type", + "sha" + ], + "type": "object" + }, + "ref": { + "description": "Fully qualified reference, such as refs/heads/main.", + "type": "string" + } + }, + "required": [ + "ref", + "object" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/remove_issue_comment_reaction.snap b/pkg/github/__toolsnaps__/remove_issue_comment_reaction.snap new file mode 100644 index 0000000000..1c0859a06a --- /dev/null +++ b/pkg/github/__toolsnaps__/remove_issue_comment_reaction.snap @@ -0,0 +1,69 @@ +{ + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Remove Reaction from Issue or Pull Request Comment" + }, + "description": "Remove a reaction from an issue or pull request comment.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "comment_id": { + "description": "The issue or pull request comment ID", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string", + "x-mcp-header": "owner" + }, + "reaction_id": { + "description": "The reaction ID to remove", + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string", + "x-mcp-header": "repo" + } + }, + "required": [ + "owner", + "repo", + "comment_id", + "reaction_id" + ], + "type": "object" + }, + "name": "remove_issue_comment_reaction", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/remove_issue_reaction.snap b/pkg/github/__toolsnaps__/remove_issue_reaction.snap new file mode 100644 index 0000000000..92f7d4b471 --- /dev/null +++ b/pkg/github/__toolsnaps__/remove_issue_reaction.snap @@ -0,0 +1,69 @@ +{ + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Remove Reaction from Issue or Pull Request" + }, + "description": "Remove a reaction from an issue or pull request.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "issue_number": { + "description": "The issue number", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string", + "x-mcp-header": "owner" + }, + "reaction_id": { + "description": "The reaction ID to remove", + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string", + "x-mcp-header": "repo" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "reaction_id" + ], + "type": "object" + }, + "name": "remove_issue_reaction", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/remove_pull_request_review_comment_reaction.snap b/pkg/github/__toolsnaps__/remove_pull_request_review_comment_reaction.snap new file mode 100644 index 0000000000..da5f8e89dc --- /dev/null +++ b/pkg/github/__toolsnaps__/remove_pull_request_review_comment_reaction.snap @@ -0,0 +1,69 @@ +{ + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Remove Pull Request Review Comment Reaction" + }, + "description": "Remove a reaction from a pull request review comment.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "comment_id": { + "description": "The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string", + "x-mcp-header": "owner" + }, + "reaction_id": { + "description": "The reaction ID to remove", + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string", + "x-mcp-header": "repo" + } + }, + "required": [ + "owner", + "repo", + "comment_id", + "reaction_id" + ], + "type": "object" + }, + "name": "remove_pull_request_review_comment_reaction", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/remove_sub_issue.snap b/pkg/github/__toolsnaps__/remove_sub_issue.snap index 4f07c85f41..3386d20e95 100644 --- a/pkg/github/__toolsnaps__/remove_sub_issue.snap +++ b/pkg/github/__toolsnaps__/remove_sub_issue.snap @@ -7,6 +7,18 @@ "title": "Remove Sub-Issue" }, "description": "Remove a sub-issue from a parent issue.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "issue_number": { @@ -16,11 +28,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "sub_issue_id": { "description": "The ID of the sub-issue to remove. ID is not the same as issue number", @@ -35,5 +49,217 @@ ], "type": "object" }, - "name": "remove_sub_issue" + "name": "remove_sub_issue", + "outputSchema": { + "additionalProperties": false, + "properties": { + "issue": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "closed_at": { + "description": "Closure time (RFC 3339), when closed.", + "type": "string" + }, + "comments": { + "description": "Number of comments.", + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "description": "Creation time (RFC 3339).", + "type": "string" + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "description": "REST issue field type.", + "enum": [ + "text", + "number", + "date", + "single_select" + ], + "type": "string" + }, + "issue_field_id": { + "type": "integer" + }, + "value": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + }, + "required": [ + "issue_field_id", + "data_type", + "value" + ], + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "html_url": { + "description": "Human-readable issue link.", + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "issue_type": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "milestone": { + "type": "string" + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "state": { + "description": "Issue state (lowercase REST value).", + "enum": [ + "open", + "closed" + ], + "type": [ + "null", + "string" + ] + }, + "state_reason": { + "enum": [ + "completed", + "not_planned", + "reopened", + "duplicate" + ], + "type": [ + "null", + "string" + ] + }, + "sub_issues_summary": { + "additionalProperties": false, + "properties": { + "completed": { + "description": "Number of completed sub-issues.", + "type": "integer" + }, + "percent_completed": { + "description": "Completed sub-issues as a percentage (0-100).", + "type": "integer" + }, + "total": { + "description": "Total number of sub-issues.", + "type": "integer" + } + }, + "required": [ + "total", + "completed", + "percent_completed" + ], + "type": [ + "null", + "object" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last update time (RFC 3339).", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "assignees" + ], + "type": [ + "object", + "null" + ] + }, + "method": { + "enum": [ + "add", + "remove", + "reprioritize" + ], + "type": "string" + } + }, + "required": [ + "method", + "issue" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/repository_ruleset_read.snap b/pkg/github/__toolsnaps__/repository_ruleset_read.snap new file mode 100644 index 0000000000..c3f24b219b --- /dev/null +++ b/pkg/github/__toolsnaps__/repository_ruleset_read.snap @@ -0,0 +1,550 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Read repository rulesets" + }, + "description": "Read rulesets at the repository, organization, or enterprise level, and rule suites at the repository or organization level. Select the level with the 'level' parameter and the operation with the 'method' parameter.", + "inputSchema": { + "properties": { + "actor_name": { + "description": "The handle for the GitHub user account to filter rule suites on. Used by the 'list_rule_suites' method.", + "type": "string" + }, + "branch": { + "description": "Branch name. Required for the 'get_rules_for_branch' method.", + "type": "string" + }, + "enterprise": { + "description": "Enterprise slug. Required when level is 'enterprise'.", + "type": "string" + }, + "evaluate_status": { + "description": "Filter rule suites by ruleset evaluation mode. Used by the 'list_rule_suites' method.", + "enum": [ + "all", + "active", + "evaluate" + ], + "type": "string" + }, + "includes_parents": { + "description": "Include rulesets configured at higher levels that also apply. Defaults to true. Used by the 'get' and 'list' methods at the repository level.", + "type": "boolean" + }, + "level": { + "description": "The level at which the ruleset is configured:\n- 'repository': A ruleset on a single repository (requires 'owner' and 'repo').\n- 'organization': A ruleset covering repositories in an organization (requires 'org').\n- 'enterprise': A ruleset covering repositories across an enterprise (requires 'enterprise').", + "enum": [ + "repository", + "organization", + "enterprise" + ], + "type": "string" + }, + "method": { + "description": "Operation to perform:\n- 'get': Get a specific ruleset by ID (requires 'ruleset_id'). Supported at every level.\n- 'list': List all rulesets. Supported at every level.\n- 'get_rules_for_branch': Get all rules that apply to a branch (requires 'branch'). Repository level only.\n- 'list_rule_suites': List rule suites, the evaluations of rules against pushes. Repository and organization levels only.\n- 'get_rule_suite': Get a specific rule suite by ID (requires 'rule_suite_id'). Repository and organization levels only.", + "enum": [ + "get", + "list", + "get_rules_for_branch", + "list_rule_suites", + "get_rule_suite" + ], + "type": "string" + }, + "org": { + "description": "Organization name. Required when level is 'organization'.", + "type": "string" + }, + "owner": { + "description": "Repository owner. Required when level is 'repository'.", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "ref": { + "description": "The name of the ref (branch, tag, etc.) to filter rule suites by. Used by the 'list_rule_suites' method.", + "type": "string" + }, + "repo": { + "description": "Repository name. Required when level is 'repository'.", + "type": "string" + }, + "repository_name": { + "description": "Repository name to filter rule suites by. Used by the 'list_rule_suites' method at the organization level.", + "type": "string" + }, + "rule_suite_id": { + "description": "Rule suite ID. Required for the 'get_rule_suite' method.", + "type": "number" + }, + "rule_suite_result": { + "description": "The rule suite result to filter by. Used by the 'list_rule_suites' method.", + "enum": [ + "pass", + "fail", + "bypass", + "all" + ], + "type": "string" + }, + "ruleset_id": { + "description": "Ruleset ID. Required for the 'get' method.", + "type": "number" + }, + "time_period": { + "description": "The time period to filter rule suites by. Used by the 'list_rule_suites' method.", + "enum": [ + "hour", + "day", + "week", + "month" + ], + "type": "string" + } + }, + "required": [ + "level", + "method" + ], + "type": "object" + }, + "name": "repository_ruleset_read", + "outputSchema": { + "$defs": { + "idCondition": { + "additionalProperties": false, + "properties": { + "organization_ids": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "repository_ids": { + "items": { + "type": "integer" + }, + "type": "array" + } + }, + "type": "object" + }, + "nameCondition": { + "additionalProperties": false, + "properties": { + "exclude": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "include": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "protected": { + "type": "boolean" + } + }, + "required": [ + "include", + "exclude" + ], + "type": "object" + }, + "propertyCondition": { + "additionalProperties": false, + "properties": { + "exclude": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "property_values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "source": { + "type": "string" + } + }, + "required": [ + "name", + "property_values" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "include": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "property_values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "source": { + "type": "string" + } + }, + "required": [ + "name", + "property_values" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "include", + "exclude" + ], + "type": "object" + }, + "rule": { + "additionalProperties": false, + "properties": { + "parameters": { + "description": "User-configured parameters for this rule type.", + "type": "object" + }, + "ruleset_id": { + "type": "integer" + }, + "ruleset_source": { + "type": "string" + }, + "ruleset_source_type": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "ruleSuite": { + "additionalProperties": false, + "properties": { + "actor_id": { + "type": "integer" + }, + "actor_name": { + "type": "string" + }, + "after_sha": { + "type": "string" + }, + "before_sha": { + "type": "string" + }, + "evaluation_result": { + "description": "Result of rules evaluated without enforcement, distinct from the enforced result.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "pushed_at": { + "description": "RFC3339 push time.", + "format": "date-time", + "type": "string" + }, + "ref": { + "type": "string" + }, + "repository_id": { + "type": "integer" + }, + "repository_name": { + "type": "string" + }, + "result": { + "enum": [ + "pass", + "fail", + "bypass" + ], + "type": "string" + }, + "rule_evaluations": { + "items": { + "additionalProperties": false, + "properties": { + "details": { + "type": "string" + }, + "enforcement": { + "type": "string" + }, + "result": { + "type": "string" + }, + "rule_source": { + "additionalProperties": false, + "properties": { + "id": { + "type": [ + "null", + "integer" + ] + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + }, + "rule_type": { + "type": "string" + } + }, + "required": [ + "result", + "rule_type" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "id", + "result" + ], + "type": "object" + }, + "ruleset": { + "additionalProperties": false, + "properties": { + "bypass_actors": { + "items": { + "additionalProperties": false, + "properties": { + "actor_id": { + "type": "integer" + }, + "actor_type": { + "type": "string" + }, + "bypass_mode": { + "type": "string" + } + }, + "required": [ + "actor_type", + "bypass_mode" + ], + "type": "object" + }, + "type": "array" + }, + "conditions": { + "additionalProperties": false, + "properties": { + "organization_id": { + "$ref": "#/$defs/idCondition", + "type": "object" + }, + "organization_name": { + "$ref": "#/$defs/nameCondition", + "type": "object" + }, + "organization_property": { + "$ref": "#/$defs/propertyCondition", + "type": "object" + }, + "ref_name": { + "$ref": "#/$defs/nameCondition", + "type": "object" + }, + "repository_id": { + "$ref": "#/$defs/idCondition", + "type": "object" + }, + "repository_name": { + "$ref": "#/$defs/nameCondition", + "type": "object" + }, + "repository_property": { + "$ref": "#/$defs/propertyCondition", + "type": "object" + } + }, + "type": "object" + }, + "created_at": { + "description": "RFC3339 creation time.", + "format": "date-time", + "type": "string" + }, + "current_user_can_bypass": { + "type": "string" + }, + "enforcement": { + "enum": [ + "", + "disabled", + "active", + "evaluate" + ], + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "rules": { + "items": { + "$ref": "#/$defs/rule", + "type": "object" + }, + "type": "array" + }, + "source": { + "type": "string" + }, + "source_type": { + "enum": [ + "Repository", + "Organization", + "Enterprise" + ], + "type": "string" + }, + "target": { + "enum": [ + "branch", + "tag", + "push", + "repository" + ], + "type": "string" + }, + "updated_at": { + "description": "RFC3339 last update time.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "name", + "source", + "enforcement" + ], + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "level": { + "enum": [ + "repository", + "organization", + "enterprise" + ], + "type": "string" + }, + "method": { + "enum": [ + "get", + "list", + "get_rules_for_branch", + "list_rule_suites", + "get_rule_suite" + ], + "type": "string" + }, + "rule_suite": { + "$ref": "#/$defs/ruleSuite", + "type": "object" + }, + "rule_suites": { + "items": { + "$ref": "#/$defs/ruleSuite", + "type": "object" + }, + "type": "array" + }, + "rules": { + "items": { + "$ref": "#/$defs/rule", + "type": "object" + }, + "type": "array" + }, + "ruleset": { + "$ref": "#/$defs/ruleset", + "type": "object" + }, + "rulesets": { + "items": { + "$ref": "#/$defs/ruleset", + "type": "object" + }, + "type": "array" + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "method", + "level" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap b/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap index bb981b74ea..591ec1c095 100644 --- a/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap +++ b/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap @@ -7,6 +7,18 @@ "title": "Reprioritize Sub-Issue" }, "description": "Reprioritize (reorder) a sub-issue relative to other sub-issues.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "after_id": { @@ -24,11 +36,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "sub_issue_id": { "description": "The ID of the sub-issue to reorder. ID is not the same as issue number", @@ -43,5 +57,217 @@ ], "type": "object" }, - "name": "reprioritize_sub_issue" + "name": "reprioritize_sub_issue", + "outputSchema": { + "additionalProperties": false, + "properties": { + "issue": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "closed_at": { + "description": "Closure time (RFC 3339), when closed.", + "type": "string" + }, + "comments": { + "description": "Number of comments.", + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "description": "Creation time (RFC 3339).", + "type": "string" + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "description": "REST issue field type.", + "enum": [ + "text", + "number", + "date", + "single_select" + ], + "type": "string" + }, + "issue_field_id": { + "type": "integer" + }, + "value": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + }, + "required": [ + "issue_field_id", + "data_type", + "value" + ], + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "html_url": { + "description": "Human-readable issue link.", + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "issue_type": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "milestone": { + "type": "string" + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "state": { + "description": "Issue state (lowercase REST value).", + "enum": [ + "open", + "closed" + ], + "type": [ + "null", + "string" + ] + }, + "state_reason": { + "enum": [ + "completed", + "not_planned", + "reopened", + "duplicate" + ], + "type": [ + "null", + "string" + ] + }, + "sub_issues_summary": { + "additionalProperties": false, + "properties": { + "completed": { + "description": "Number of completed sub-issues.", + "type": "integer" + }, + "percent_completed": { + "description": "Completed sub-issues as a percentage (0-100).", + "type": "integer" + }, + "total": { + "description": "Total number of sub-issues.", + "type": "integer" + } + }, + "required": [ + "total", + "completed", + "percent_completed" + ], + "type": [ + "null", + "object" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last update time (RFC 3339).", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "assignees" + ], + "type": [ + "object", + "null" + ] + }, + "method": { + "enum": [ + "add", + "remove", + "reprioritize" + ], + "type": "string" + } + }, + "required": [ + "method", + "issue" + ], + "type": "object" + } } \ 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 5e8ecdca19..2847b5f534 100644 --- a/pkg/github/__toolsnaps__/request_copilot_review.snap +++ b/pkg/github/__toolsnaps__/request_copilot_review.snap @@ -39,5 +39,18 @@ ], "type": "object" }, - "name": "request_copilot_review" + "name": "request_copilot_review", + "outputSchema": { + "additionalProperties": false, + "properties": { + "status": { + "description": "Outcome of the Copilot review request; requested on success.", + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + } } \ 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 index 607f540d1b..56eb0bad90 100644 --- a/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap +++ b/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap @@ -7,11 +7,24 @@ "title": "Request Pull Request Reviewers" }, "description": "Request reviewers for a pull request.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "pullNumber": { "description": "The pull request number", @@ -20,7 +33,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "reviewers": { "description": "GitHub usernames or ORG/team-slug team reviewers to request reviews from", @@ -38,5 +52,24 @@ ], "type": "object" }, - "name": "request_pull_request_reviewers" + "name": "request_pull_request_reviewers", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/resolve_review_thread.snap b/pkg/github/__toolsnaps__/resolve_review_thread.snap index 5f2b21b36f..dc01720117 100644 --- a/pkg/github/__toolsnaps__/resolve_review_thread.snap +++ b/pkg/github/__toolsnaps__/resolve_review_thread.snap @@ -7,6 +7,18 @@ "title": "Resolve Review Thread" }, "description": "Resolve a review thread on a pull request. Resolving an already-resolved thread is a no-op.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "threadID": { @@ -19,5 +31,20 @@ ], "type": "object" }, - "name": "resolve_review_thread" + "name": "resolve_review_thread", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/resolve_review_thread_resolution_reason.snap b/pkg/github/__toolsnaps__/resolve_review_thread_resolution_reason.snap new file mode 100644 index 0000000000..b8bf2c8c7d --- /dev/null +++ b/pkg/github/__toolsnaps__/resolve_review_thread_resolution_reason.snap @@ -0,0 +1,54 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Resolve Review Thread" + }, + "description": "Resolve a review thread on a pull request. Resolving an already-resolved thread is a no-op.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "resolutionReason": { + "description": "Optional reason for resolving a Copilot code review thread: addressed, wont-fix, or invalid.", + "type": "string" + }, + "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", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_code_output.snap b/pkg/github/__toolsnaps__/search_code_output.snap new file mode 100644 index 0000000000..68ebc8c905 --- /dev/null +++ b/pkg/github/__toolsnaps__/search_code_output.snap @@ -0,0 +1,136 @@ +{ + "inputSchema": null, + "name": "search_code", + "outputSchema": { + "additionalProperties": false, + "properties": { + "incomplete_results": { + "description": "Whether GitHub returned an incomplete result set.", + "type": "boolean" + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "repository": { + "type": [ + "null", + "string" + ] + }, + "sha": { + "type": [ + "null", + "string" + ] + }, + "text_matches": { + "description": "Matching code fragments returned by GitHub.", + "items": { + "additionalProperties": false, + "properties": { + "fragment": { + "description": "Source fragment containing the match.", + "type": [ + "null", + "string" + ] + }, + "matches": { + "description": "Matched text and its offsets in the fragment.", + "items": { + "additionalProperties": false, + "properties": { + "indices": { + "description": "Start and end offsets of the match in the fragment.", + "items": { + "type": "integer" + }, + "type": [ + "null", + "array" + ] + }, + "text": { + "description": "Matched text.", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "object_type": { + "description": "GitHub object type containing the match.", + "type": [ + "null", + "string" + ] + }, + "object_url": { + "description": "API URL of the matched file or object.", + "type": [ + "null", + "string" + ] + }, + "property": { + "description": "Property containing the matched text.", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "description": "Total number of matching code results.", + "type": "integer" + } + }, + "required": [ + "total_count", + "incomplete_results", + "items" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_commits.snap b/pkg/github/__toolsnaps__/search_commits.snap index 8221dee480..b9be295c88 100644 --- a/pkg/github/__toolsnaps__/search_commits.snap +++ b/pkg/github/__toolsnaps__/search_commits.snap @@ -44,5 +44,358 @@ ], "type": "object" }, - "name": "search_commits" + "name": "search_commits", + "outputSchema": { + "additionalProperties": false, + "properties": { + "incomplete_results": { + "type": "boolean" + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "commit": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "committer": { + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + }, + "committer": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "files": { + "items": { + "additionalProperties": false, + "properties": { + "additions": { + "type": "integer" + }, + "changes": { + "type": "integer" + }, + "deletions": { + "type": "integer" + }, + "filename": { + "type": "string" + }, + "patch": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "filename" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "html_url": { + "type": "string" + }, + "repository": { + "additionalProperties": false, + "properties": { + "full_name": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "private": { + "type": "boolean" + } + }, + "required": [ + "full_name" + ], + "type": [ + "null", + "object" + ] + }, + "sha": { + "type": "string" + }, + "stats": { + "additionalProperties": false, + "properties": { + "additions": { + "type": "integer" + }, + "deletions": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "sha", + "html_url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "incomplete_results", + "items" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_issues_output.snap b/pkg/github/__toolsnaps__/search_issues_output.snap new file mode 100644 index 0000000000..2f493407e7 --- /dev/null +++ b/pkg/github/__toolsnaps__/search_issues_output.snap @@ -0,0 +1,367 @@ +{ + "inputSchema": null, + "name": "search_issues", + "outputSchema": { + "additionalProperties": false, + "properties": { + "incomplete_results": { + "type": [ + "null", + "boolean" + ] + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "assignee": { + "type": [ + "null", + "string" + ] + }, + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "author_association": { + "type": [ + "null", + "string" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "closed_at": { + "type": [ + "null", + "string" + ] + }, + "closed_by": { + "type": [ + "null", + "string" + ] + }, + "comments": { + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "type": [ + "null", + "string" + ] + }, + "draft": { + "type": [ + "null", + "boolean" + ] + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "value": { + "type": "string" + }, + "values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "locked": { + "type": [ + "null", + "boolean" + ] + }, + "milestone": { + "type": [ + "null", + "string" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "pull_request": { + "additionalProperties": false, + "properties": { + "diff_url": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "patch_url": { + "type": [ + "null", + "string" + ] + }, + "url": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "reactions": { + "additionalProperties": false, + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": [ + "null", + "object" + ] + }, + "repository_url": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "state_reason": { + "type": [ + "null", + "string" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "type": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "type": [ + "null", + "string" + ] + }, + "user": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": [ + "null", + "integer" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_orgs.snap b/pkg/github/__toolsnaps__/search_orgs.snap index 9796c6105d..d9d66a34e4 100644 --- a/pkg/github/__toolsnaps__/search_orgs.snap +++ b/pkg/github/__toolsnaps__/search_orgs.snap @@ -45,5 +45,122 @@ ], "type": "object" }, - "name": "search_orgs" + "name": "search_orgs", + "outputSchema": { + "additionalProperties": false, + "properties": { + "incomplete_results": { + "type": "boolean" + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "incomplete_results", + "items" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_pull_requests_output.snap b/pkg/github/__toolsnaps__/search_pull_requests_output.snap new file mode 100644 index 0000000000..0a9eaac648 --- /dev/null +++ b/pkg/github/__toolsnaps__/search_pull_requests_output.snap @@ -0,0 +1,367 @@ +{ + "inputSchema": null, + "name": "search_pull_requests", + "outputSchema": { + "additionalProperties": false, + "properties": { + "incomplete_results": { + "type": [ + "null", + "boolean" + ] + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "assignee": { + "type": [ + "null", + "string" + ] + }, + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "author_association": { + "type": [ + "null", + "string" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "closed_at": { + "type": [ + "null", + "string" + ] + }, + "closed_by": { + "type": [ + "null", + "string" + ] + }, + "comments": { + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "type": [ + "null", + "string" + ] + }, + "draft": { + "type": [ + "null", + "boolean" + ] + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "value": { + "type": "string" + }, + "values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "locked": { + "type": [ + "null", + "boolean" + ] + }, + "milestone": { + "type": [ + "null", + "string" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "pull_request": { + "additionalProperties": false, + "properties": { + "diff_url": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "patch_url": { + "type": [ + "null", + "string" + ] + }, + "url": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "reactions": { + "additionalProperties": false, + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": [ + "null", + "object" + ] + }, + "repository_url": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "state_reason": { + "type": [ + "null", + "string" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "type": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "type": [ + "null", + "string" + ] + }, + "user": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": [ + "null", + "integer" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_repositories.snap b/pkg/github/__toolsnaps__/search_repositories.snap index 23b1d5e839..6c64889860 100644 --- a/pkg/github/__toolsnaps__/search_repositories.snap +++ b/pkg/github/__toolsnaps__/search_repositories.snap @@ -9,7 +9,7 @@ "properties": { "minimal_output": { "default": true, - "description": "Return minimal repository information (default: true). When false, returns full GitHub API repository objects.", + "description": "Return minimal repository information (default: true). When false, modern clients receive additional curated repository details, not the complete GitHub API object. Legacy clients retain full GitHub API repository objects.", "type": "boolean" }, "order": { @@ -51,5 +51,229 @@ ], "type": "object" }, - "name": "search_repositories" + "name": "search_repositories", + "outputSchema": { + "additionalProperties": false, + "properties": { + "incomplete_results": { + "type": "boolean" + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "archived": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "default_branch": { + "type": "string" + }, + "description": { + "type": "string" + }, + "disabled": { + "description": "Whether the repository is disabled", + "type": [ + "null", + "boolean" + ] + }, + "fork": { + "type": "boolean" + }, + "forks_count": { + "type": "integer" + }, + "full_name": { + "type": "string" + }, + "has_discussions": { + "type": [ + "null", + "boolean" + ] + }, + "has_issues": { + "type": [ + "null", + "boolean" + ] + }, + "has_projects": { + "type": [ + "null", + "boolean" + ] + }, + "has_wiki": { + "type": [ + "null", + "boolean" + ] + }, + "homepage": { + "description": "Repository homepage URL", + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_template": { + "description": "Whether the repository is a template", + "type": [ + "null", + "boolean" + ] + }, + "language": { + "type": "string" + }, + "license": { + "additionalProperties": false, + "description": "Repository license", + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "spdx_id": { + "description": "SPDX license identifier", + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "name": { + "type": "string" + }, + "open_issues_count": { + "type": "integer" + }, + "permissions": { + "additionalProperties": false, + "description": "Authenticated user's repository permissions", + "properties": { + "admin": { + "type": [ + "null", + "boolean" + ] + }, + "maintain": { + "type": [ + "null", + "boolean" + ] + }, + "pull": { + "type": [ + "null", + "boolean" + ] + }, + "push": { + "type": [ + "null", + "boolean" + ] + }, + "triage": { + "type": [ + "null", + "boolean" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "private": { + "type": "boolean" + }, + "pushed_at": { + "description": "Last push time as an ISO 8601 UTC timestamp", + "type": "string" + }, + "size": { + "description": "Repository size in kilobytes", + "type": [ + "null", + "integer" + ] + }, + "stargazers_count": { + "type": "integer" + }, + "topics": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "updated_at": { + "type": "string" + }, + "visibility": { + "description": "Repository visibility", + "enum": [ + "public", + "private", + "internal" + ], + "type": "string" + }, + "watchers_count": { + "description": "Number of watchers", + "type": [ + "null", + "integer" + ] + } + }, + "required": [ + "id", + "name", + "full_name", + "html_url", + "stargazers_count", + "forks_count", + "open_issues_count", + "private", + "fork", + "archived" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "incomplete_results", + "items" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_users.snap b/pkg/github/__toolsnaps__/search_users.snap index 27bd2eb081..613d3d2d51 100644 --- a/pkg/github/__toolsnaps__/search_users.snap +++ b/pkg/github/__toolsnaps__/search_users.snap @@ -45,5 +45,122 @@ ], "type": "object" }, - "name": "search_users" + "name": "search_users", + "outputSchema": { + "additionalProperties": false, + "properties": { + "incomplete_results": { + "type": "boolean" + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "incomplete_results", + "items" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/set_issue_fields.snap b/pkg/github/__toolsnaps__/set_issue_fields.snap index c5b89077ac..64b2731bea 100644 --- a/pkg/github/__toolsnaps__/set_issue_fields.snap +++ b/pkg/github/__toolsnaps__/set_issue_fields.snap @@ -7,6 +7,18 @@ "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.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "fields": { @@ -71,11 +83,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -86,5 +100,24 @@ ], "type": "object" }, - "name": "set_issue_fields" + "name": "set_issue_fields", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/star_repository.snap b/pkg/github/__toolsnaps__/star_repository.snap index 4344d0ee06..7b0eef9367 100644 --- a/pkg/github/__toolsnaps__/star_repository.snap +++ b/pkg/github/__toolsnaps__/star_repository.snap @@ -21,11 +21,13 @@ "properties": { "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -34,5 +36,20 @@ ], "type": "object" }, - "name": "star_repository" + "name": "star_repository", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ 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 9dc2776c32..c3556ad81a 100644 --- a/pkg/github/__toolsnaps__/sub_issue_write.snap +++ b/pkg/github/__toolsnaps__/sub_issue_write.snap @@ -49,5 +49,217 @@ ], "type": "object" }, - "name": "sub_issue_write" + "name": "sub_issue_write", + "outputSchema": { + "additionalProperties": false, + "properties": { + "issue": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "closed_at": { + "description": "Closure time (RFC 3339), when closed.", + "type": "string" + }, + "comments": { + "description": "Number of comments.", + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "description": "Creation time (RFC 3339).", + "type": "string" + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "description": "REST issue field type.", + "enum": [ + "text", + "number", + "date", + "single_select" + ], + "type": "string" + }, + "issue_field_id": { + "type": "integer" + }, + "value": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + }, + "required": [ + "issue_field_id", + "data_type", + "value" + ], + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "html_url": { + "description": "Human-readable issue link.", + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "issue_type": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "milestone": { + "type": "string" + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "state": { + "description": "Issue state (lowercase REST value).", + "enum": [ + "open", + "closed" + ], + "type": [ + "null", + "string" + ] + }, + "state_reason": { + "enum": [ + "completed", + "not_planned", + "reopened", + "duplicate" + ], + "type": [ + "null", + "string" + ] + }, + "sub_issues_summary": { + "additionalProperties": false, + "properties": { + "completed": { + "description": "Number of completed sub-issues.", + "type": "integer" + }, + "percent_completed": { + "description": "Completed sub-issues as a percentage (0-100).", + "type": "integer" + }, + "total": { + "description": "Total number of sub-issues.", + "type": "integer" + } + }, + "required": [ + "total", + "completed", + "percent_completed" + ], + "type": [ + "null", + "object" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last update time (RFC 3339).", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "assignees" + ], + "type": [ + "object", + "null" + ] + }, + "method": { + "enum": [ + "add", + "remove", + "reprioritize" + ], + "type": "string" + } + }, + "required": [ + "method", + "issue" + ], + "type": "object" + } } \ 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 index 12438ffe71..818b6fd198 100644 --- a/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap +++ b/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap @@ -7,6 +7,18 @@ "title": "Submit Pending Pull Request Review" }, "description": "Submit a pending pull request review.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "body": { @@ -24,7 +36,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "pullNumber": { "description": "The pull request number", @@ -33,7 +46,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -44,5 +58,20 @@ ], "type": "object" }, - "name": "submit_pending_pull_request_review" + "name": "submit_pending_pull_request_review", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/ui_get.snap b/pkg/github/__toolsnaps__/ui_get.snap index 85b562156d..012089497f 100644 --- a/pkg/github/__toolsnaps__/ui_get.snap +++ b/pkg/github/__toolsnaps__/ui_get.snap @@ -42,5 +42,686 @@ ], "type": "object" }, - "name": "ui_get" + "name": "ui_get", + "outputSchema": { + "additionalProperties": false, + "oneOf": [ + { + "properties": { + "assignees": { + "not": { + "description": "Any value." + } + }, + "branches": { + "not": { + "description": "Any value." + } + }, + "issue_fields": { + "not": { + "description": "Any value." + } + }, + "issue_types": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "labels" + ] + }, + "milestones": { + "not": { + "description": "Any value." + } + }, + "reviewers": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "labels" + ] + }, + { + "properties": { + "branches": { + "not": { + "description": "Any value." + } + }, + "issue_fields": { + "not": { + "description": "Any value." + } + }, + "issue_types": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "assignees" + ] + }, + "milestones": { + "not": { + "description": "Any value." + } + }, + "reviewers": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "assignees" + ] + }, + { + "properties": { + "assignees": { + "not": { + "description": "Any value." + } + }, + "branches": { + "not": { + "description": "Any value." + } + }, + "issue_fields": { + "not": { + "description": "Any value." + } + }, + "issue_types": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "milestones" + ] + }, + "reviewers": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "milestones" + ] + }, + { + "properties": { + "assignees": { + "not": { + "description": "Any value." + } + }, + "branches": { + "not": { + "description": "Any value." + } + }, + "issue_fields": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "issue_types" + ] + }, + "milestones": { + "not": { + "description": "Any value." + } + }, + "reviewers": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "issue_types" + ] + }, + { + "properties": { + "assignees": { + "not": { + "description": "Any value." + } + }, + "issue_fields": { + "not": { + "description": "Any value." + } + }, + "issue_types": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "branches" + ] + }, + "milestones": { + "not": { + "description": "Any value." + } + }, + "reviewers": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "branches" + ] + }, + { + "properties": { + "assignees": { + "not": { + "description": "Any value." + } + }, + "branches": { + "not": { + "description": "Any value." + } + }, + "issue_types": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "issue_fields" + ] + }, + "milestones": { + "not": { + "description": "Any value." + } + }, + "reviewers": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "issue_fields" + ] + }, + { + "properties": { + "assignees": { + "not": { + "description": "Any value." + } + }, + "branches": { + "not": { + "description": "Any value." + } + }, + "issue_fields": { + "not": { + "description": "Any value." + } + }, + "issue_types": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "reviewers" + ] + }, + "milestones": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "reviewers" + ] + } + ], + "properties": { + "assignees": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login", + "avatar_url" + ], + "type": "object" + }, + "type": "array" + }, + "has_more": { + "description": "True when results were truncated at the pagination limit.", + "type": "boolean" + }, + "totalCount": { + "description": "Total number of matching assignees.", + "type": "integer" + } + }, + "required": [ + "assignees", + "totalCount", + "has_more" + ], + "type": "object" + }, + "branches": { + "additionalProperties": false, + "properties": { + "branches": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "protected": { + "type": "boolean" + }, + "sha": { + "type": "string" + } + }, + "required": [ + "name", + "sha", + "protected" + ], + "type": "object" + }, + "type": "array" + }, + "has_more": { + "description": "True when results were truncated at the pagination limit.", + "type": "boolean" + }, + "totalCount": { + "description": "Number of branches returned.", + "type": "integer" + } + }, + "required": [ + "branches", + "totalCount", + "has_more" + ], + "type": "object" + }, + "issue_fields": { + "additionalProperties": false, + "properties": { + "fields": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "enum": [ + "text", + "number", + "date", + "single_select" + ], + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "options": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "description", + "color" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "id", + "name", + "data_type", + "description" + ], + "type": "object" + }, + "type": "array" + }, + "totalCount": { + "description": "Number of supported issue fields returned.", + "type": "integer" + } + }, + "required": [ + "fields", + "totalCount" + ], + "type": "object" + }, + "issue_types": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "type": [ + "null", + "string" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "is_enabled": { + "type": [ + "null", + "boolean" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "array", + "null" + ] + }, + "labels": { + "additionalProperties": false, + "properties": { + "has_more": { + "description": "True when results were truncated at the pagination limit.", + "type": "boolean" + }, + "labels": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "color", + "description" + ], + "type": "object" + }, + "type": "array" + }, + "totalCount": { + "description": "Total number of matching labels.", + "type": "integer" + } + }, + "required": [ + "labels", + "totalCount", + "has_more" + ], + "type": "object" + }, + "method": { + "description": "The method that produced the corresponding typed output.", + "enum": [ + "labels", + "assignees", + "milestones", + "issue_types", + "branches", + "issue_fields", + "reviewers" + ], + "type": "string" + }, + "milestones": { + "additionalProperties": false, + "properties": { + "has_more": { + "description": "True when results were truncated at the pagination limit.", + "type": "boolean" + }, + "milestones": { + "items": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "due_on": { + "description": "Due date in YYYY-MM-DD form, or empty when no due date is set.", + "type": "string" + }, + "number": { + "description": "Milestone number.", + "type": "integer" + }, + "open_issues": { + "description": "Number of open issues assigned to the milestone.", + "type": "integer" + }, + "state": { + "enum": [ + "open", + "closed" + ], + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "description", + "state", + "open_issues", + "due_on" + ], + "type": "object" + }, + "type": "array" + }, + "totalCount": { + "description": "Total number of matching milestones.", + "type": "integer" + } + }, + "required": [ + "milestones", + "totalCount", + "has_more" + ], + "type": "object" + }, + "reviewers": { + "additionalProperties": false, + "properties": { + "has_more": { + "type": "boolean" + }, + "teams": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "slug", + "name", + "org" + ], + "type": "object" + }, + "type": "array" + }, + "totalCount": { + "description": "Total number of matching users and teams.", + "type": "integer" + }, + "users": { + "items": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login", + "avatar_url" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "users", + "teams", + "totalCount", + "has_more" + ], + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unhide_issue_comment.snap b/pkg/github/__toolsnaps__/unhide_issue_comment.snap new file mode 100644 index 0000000000..2441c86bd4 --- /dev/null +++ b/pkg/github/__toolsnaps__/unhide_issue_comment.snap @@ -0,0 +1,68 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Unhide Issue Comment" + }, + "description": "Unhide (unminimize) a previously hidden comment on an issue, or conversation comment on a pull request. Requires triage or write access to the repository, or being its author.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "comment_id": { + "description": "The numeric ID of the issue or pull request conversation comment", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string", + "x-mcp-header": "owner" + }, + "repo": { + "description": "Repository name", + "type": "string", + "x-mcp-header": "repo" + } + }, + "required": [ + "owner", + "repo", + "comment_id" + ], + "type": "object" + }, + "name": "unhide_issue_comment", + "outputSchema": { + "additionalProperties": false, + "properties": { + "is_minimized": { + "type": "boolean" + }, + "minimized_reason": { + "description": "GitHub's lowercase, hyphenated reason (for example off-topic); omitted when unhidden.", + "type": "string" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "node_id", + "is_minimized" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unhide_pull_request_review.snap b/pkg/github/__toolsnaps__/unhide_pull_request_review.snap new file mode 100644 index 0000000000..ae21c63806 --- /dev/null +++ b/pkg/github/__toolsnaps__/unhide_pull_request_review.snap @@ -0,0 +1,60 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Unhide Pull Request Review" + }, + "description": "Unhide (unminimize) the previously hidden body of a submitted pull request review. Requires triage or write access to the repository, or being its author.", + "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" + }, + "review_id": { + "description": "The numeric ID of the pull request review", + "minimum": 1, + "type": "number" + } + }, + "required": [ + "owner", + "repo", + "pullNumber", + "review_id" + ], + "type": "object" + }, + "name": "unhide_pull_request_review", + "outputSchema": { + "additionalProperties": false, + "properties": { + "is_minimized": { + "type": "boolean" + }, + "minimized_reason": { + "description": "GitHub's lowercase, hyphenated reason (for example off-topic); omitted when unhidden.", + "type": "string" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "node_id", + "is_minimized" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unhide_pull_request_review_comment.snap b/pkg/github/__toolsnaps__/unhide_pull_request_review_comment.snap new file mode 100644 index 0000000000..2b81142fd2 --- /dev/null +++ b/pkg/github/__toolsnaps__/unhide_pull_request_review_comment.snap @@ -0,0 +1,54 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Unhide Pull Request Review Comment" + }, + "description": "Unhide (unminimize) a previously hidden inline review comment on a pull request diff. Requires triage or write access to the repository, or being its author.", + "inputSchema": { + "properties": { + "comment_id": { + "description": "The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "comment_id" + ], + "type": "object" + }, + "name": "unhide_pull_request_review_comment", + "outputSchema": { + "additionalProperties": false, + "properties": { + "is_minimized": { + "type": "boolean" + }, + "minimized_reason": { + "description": "GitHub's lowercase, hyphenated reason (for example off-topic); omitted when unhidden.", + "type": "string" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "node_id", + "is_minimized" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unresolve_review_thread.snap b/pkg/github/__toolsnaps__/unresolve_review_thread.snap index fe40bab3e9..815ca048fb 100644 --- a/pkg/github/__toolsnaps__/unresolve_review_thread.snap +++ b/pkg/github/__toolsnaps__/unresolve_review_thread.snap @@ -7,6 +7,18 @@ "title": "Unresolve Review Thread" }, "description": "Unresolve a previously resolved review thread on a pull request. Unresolving an already-unresolved thread is a no-op.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "threadID": { @@ -19,5 +31,20 @@ ], "type": "object" }, - "name": "unresolve_review_thread" + "name": "unresolve_review_thread", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unstar_repository.snap b/pkg/github/__toolsnaps__/unstar_repository.snap index ac8262e22b..27b8b27104 100644 --- a/pkg/github/__toolsnaps__/unstar_repository.snap +++ b/pkg/github/__toolsnaps__/unstar_repository.snap @@ -5,15 +5,29 @@ "title": "Unstar repository" }, "description": "Unstar a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACxklEQVRIidWTT2gUdxTHP29mjMnBgkqNEQsbd3ZnfrvLzEoOnoRc217roYdSEQTRS4sFQbGHgK0UqigoKtKq7a29itdAT540IYmZ3cR/bYVAiVAvMZnfvB52NVtcdyd48sHAj3nf9/3zfjMuGyjf1L/a/uGOfcv/LN3NOyO5yf1oN5tkARCLU3704N6TPHNOXgEG5MQrQ65k3+Qdy5XAmLGRNdJFEX5RVQH5wrH4jcbU3/1mcyVYxZ4APMfqWYt7BnAyV3Ol6CswWqsNC3oYuJUk04/au/8V5EgQ7N31TgKFQmHQte5pYADXfv+64drvAM862clCoTDYi0MAwrBesEJV0TJISclKgpSAjwBH0JvNB9MHOwdLJrqhyJdABvypaFNwmqBNQRquMjs/f/+xBEE0ah1ZZP3Cl4Em0BRoZGjTy17eTpLkRadAEARbUmfzpw5SUigDpfazrQ1RizMKIL6Jrvkm1mIYX+kVN08Vw/iKb2L1TXStwzRSDOMLrUb9Mhv4ATtKfBOf6zD6xv2+BpQq8dVugF7k6wajSz0NFk080QLG13OKiG+iS76JtWiiH3LZWRepH+2H9U39WIs8nujW7+rQy/Tn1ilb7SegyirAJpWfcgtYodY+zvQTEGQGwKK1bv237FiqgLI2NNf51pixEWPGRv4HTQdmAVXYiAA1hScLC3f/BQjDcLtv4rNrpItrpI9LlfhqoVrdCdDGPEWodiPyugegCsz6/r4PGFg5nipfA0PATVp2D3nqfe5X4vOsDv6orMzIWxK88b2Oj497fy09fwE8BB0G2Qb85mb6bZJMJwBBEAXWkQngAOgyyBKwZ/fw1i2Tk5NpT4E9YVx2hKTdvoPoqYW5qXvd3PmVeC8qZ0A/BsiU4OH8VKPniobc9NmK9S66jv7emJv6oxvxq2oLf1KuRPttJp8NuemzXvj3s/4Daoz4w62BFyQAAAAASUVORK5CYII=", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABsklEQVRIidWUOy9EURSF1xaFAgWFd5iIUZjGD/AL/AhqCgqJelSi8EgQZLxKPf6GxihMJB6tKEa8k/kUsy83g5lzQ2MnJ/dm77XXWudknyMlCGAamErSk4S8G3gGXoDe0L66BBqzksz/ZxK5qxVAB/AIbAJbwBPQ9ZcCy8ArkAJ6/ZiW/oq8DXgAcrHctu+i87fkDcAq8Ab0x/L9nlsFGqpxmDf0SRqSlJY0EFs9Kg/CvpmNV4jvSRqTVJJ0I6kQW+eS8mZ2aUBK0oU+J+SuAliQdGRm9xUCTZJG3UjcWEsEkZQSYD4ZABs1z61GABvOtQVYlDRgxQvrH4VkxAYsRkaBumqAzS+A2uSRwbWqBoE5B+ZCRJx8zXsWQh1FIhMB2EnHzn1X/8nhrn9fA/xEmJ0kAhn/ngYIRJjMd8WfBIZUnuOzeNIfvY4KbN6xiQQykq7MrOjErcC8yhfy0qesXZIcc+2mwgI4AQ6BZiALFP3tyfl681zWMYfASSh5vb+UeeAWKAEHwGAMM+i5kmPy3lMfIpDmM46B4SrYYcdEkQ4RaPRbORK05XLPiPc0hvb8n3gHRCXiyIC2CgcAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -22,5 +36,20 @@ ], "type": "object" }, - "name": "unstar_repository" + "name": "unstar_repository", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_gist.snap b/pkg/github/__toolsnaps__/update_gist.snap index c004a8bf14..ec3ba59278 100644 --- a/pkg/github/__toolsnaps__/update_gist.snap +++ b/pkg/github/__toolsnaps__/update_gist.snap @@ -31,5 +31,21 @@ ], "type": "object" }, - "name": "update_gist" + "name": "update_gist", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_assignees.snap b/pkg/github/__toolsnaps__/update_issue_assignees.snap index 51a407c164..088992dc3b 100644 --- a/pkg/github/__toolsnaps__/update_issue_assignees.snap +++ b/pkg/github/__toolsnaps__/update_issue_assignees.snap @@ -7,6 +7,18 @@ "title": "Update Issue Assignees" }, "description": "Update the assignees of an existing issue. This replaces the current assignees with the provided list. When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "assignees": { @@ -58,11 +70,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -73,5 +87,24 @@ ], "type": "object" }, - "name": "update_issue_assignees" + "name": "update_issue_assignees", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_body.snap b/pkg/github/__toolsnaps__/update_issue_body.snap index e17043331d..69f75caa56 100644 --- a/pkg/github/__toolsnaps__/update_issue_body.snap +++ b/pkg/github/__toolsnaps__/update_issue_body.snap @@ -7,6 +7,18 @@ "title": "Update Issue Body" }, "description": "Update the body content of an existing issue.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "body": { @@ -20,11 +32,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -35,5 +49,24 @@ ], "type": "object" }, - "name": "update_issue_body" + "name": "update_issue_body", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_comment.snap b/pkg/github/__toolsnaps__/update_issue_comment.snap new file mode 100644 index 0000000000..e142883164 --- /dev/null +++ b/pkg/github/__toolsnaps__/update_issue_comment.snap @@ -0,0 +1,67 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": false, + "title": "Update issue comment" + }, + "description": "Update the body of an existing issue or pull request conversation comment. This tool cannot update pull request review comments.", + "inputSchema": { + "properties": { + "body": { + "description": "New comment content", + "minLength": 1, + "type": "string" + }, + "comment_id": { + "description": "The numeric ID of the issue or pull request conversation comment to update. Do not use a pull request review comment ID.", + "minimum": 1, + "type": "integer" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "comment_id", + "body" + ], + "type": "object" + }, + "name": "update_issue_comment", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "The web URL of the created issue comment.", + "type": "string" + }, + "id": { + "description": "The ID of the created comment or reaction.", + "type": "string" + } + }, + "required": [ + "id", + "html_url" + ], + "type": "object" + } + ], + "type": [ + "object", + "null" + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_labels.snap b/pkg/github/__toolsnaps__/update_issue_labels.snap index 2ad3877586..c0cfa73ad8 100644 --- a/pkg/github/__toolsnaps__/update_issue_labels.snap +++ b/pkg/github/__toolsnaps__/update_issue_labels.snap @@ -7,6 +7,18 @@ "title": "Update Issue Labels" }, "description": "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "issue_number": { @@ -58,11 +70,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -73,5 +87,24 @@ ], "type": "object" }, - "name": "update_issue_labels" + "name": "update_issue_labels", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_milestone.snap b/pkg/github/__toolsnaps__/update_issue_milestone.snap index cae51246b4..8795e3dddf 100644 --- a/pkg/github/__toolsnaps__/update_issue_milestone.snap +++ b/pkg/github/__toolsnaps__/update_issue_milestone.snap @@ -7,6 +7,18 @@ "title": "Update Issue Milestone" }, "description": "Update the milestone of an existing issue.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "issue_number": { @@ -21,11 +33,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -36,5 +50,24 @@ ], "type": "object" }, - "name": "update_issue_milestone" + "name": "update_issue_milestone", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_state.snap b/pkg/github/__toolsnaps__/update_issue_state.snap index 2a26e8be64..cdb9dcd946 100644 --- a/pkg/github/__toolsnaps__/update_issue_state.snap +++ b/pkg/github/__toolsnaps__/update_issue_state.snap @@ -7,6 +7,18 @@ "title": "Update Issue State" }, "description": "Update the state of an existing issue (open or closed), with an optional state reason. When closing, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the decision. Use is_suggestion to propose the change without applying it directly.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "confidence": { @@ -34,7 +46,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "rationale": { "description": "One concise sentence explaining what specifically about the issue led you to choose this state. State the concrete signal (e.g. 'The reported crash is fixed in v2.1' → completed).", @@ -43,7 +56,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "state": { "description": "The new state for the issue", @@ -71,5 +85,24 @@ ], "type": "object" }, - "name": "update_issue_state" + "name": "update_issue_state", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_title.snap b/pkg/github/__toolsnaps__/update_issue_title.snap index 48c74d98ef..69a11da93b 100644 --- a/pkg/github/__toolsnaps__/update_issue_title.snap +++ b/pkg/github/__toolsnaps__/update_issue_title.snap @@ -7,6 +7,18 @@ "title": "Update Issue Title" }, "description": "Update the title of an existing issue.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "issue_number": { @@ -16,11 +28,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "title": { "description": "The new title for the issue", @@ -35,5 +49,24 @@ ], "type": "object" }, - "name": "update_issue_title" + "name": "update_issue_title", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_type.snap b/pkg/github/__toolsnaps__/update_issue_type.snap index fbe8c90bb1..0a8215a788 100644 --- a/pkg/github/__toolsnaps__/update_issue_type.snap +++ b/pkg/github/__toolsnaps__/update_issue_type.snap @@ -7,6 +7,18 @@ "title": "Update Issue Type" }, "description": "Set or remove the type of an existing issue. Pass null to remove the current type. When setting a value, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "confidence": { @@ -41,7 +53,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "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).", @@ -50,7 +63,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -61,5 +75,24 @@ ], "type": "object" }, - "name": "update_issue_type" + "name": "update_issue_type", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ 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 ae037a3e0d..bc61f3c520 100644 --- a/pkg/github/__toolsnaps__/update_pull_request.snap +++ b/pkg/github/__toolsnaps__/update_pull_request.snap @@ -71,5 +71,44 @@ ], "type": "object" }, - "name": "update_pull_request" + "name": "update_pull_request", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "reason": { + "type": "string" + }, + "status": { + "enum": [ + "awaiting_user_submission" + ], + "type": "string" + } + }, + "required": [ + "status", + "reason" + ], + "type": "object" + } + ] + } } \ 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 index 00b4fb978a..dec6052ae6 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_body.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_body.snap @@ -7,6 +7,18 @@ "title": "Update Pull Request Body" }, "description": "Update the body description of an existing pull request.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "body": { @@ -15,7 +27,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "pullNumber": { "description": "The pull request number", @@ -24,7 +37,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -35,5 +49,24 @@ ], "type": "object" }, - "name": "update_pull_request_body" + "name": "update_pull_request_body", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ 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 633ce6fb88..83d75b4b37 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_branch.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_branch.snap @@ -31,5 +31,21 @@ ], "type": "object" }, - "name": "update_pull_request_branch" + "name": "update_pull_request_branch", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Operation result message.", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "object", + "null" + ] + } } \ 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 index 653fa3398f..d1ec856caa 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_draft_state.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_draft_state.snap @@ -7,6 +7,18 @@ "title": "Update Pull Request Draft State" }, "description": "Mark a pull request as draft or ready for review.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "draft": { @@ -15,7 +27,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "pullNumber": { "description": "The pull request number", @@ -24,7 +37,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -35,5 +49,20 @@ ], "type": "object" }, - "name": "update_pull_request_draft_state" + "name": "update_pull_request_draft_state", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ 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 index 440533e9b7..aefdde8c42 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_state.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_state.snap @@ -7,11 +7,24 @@ "title": "Update Pull Request State" }, "description": "Update the state of an existing pull request (open or closed).", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "pullNumber": { "description": "The pull request number", @@ -20,7 +33,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "state": { "description": "The new state for the pull request", @@ -39,5 +53,24 @@ ], "type": "object" }, - "name": "update_pull_request_state" + "name": "update_pull_request_state", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ 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 index 1ac5455f77..7ecf5d4600 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_title.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_title.snap @@ -7,11 +7,24 @@ "title": "Update Pull Request Title" }, "description": "Update the title of an existing pull request.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "pullNumber": { "description": "The pull request number", @@ -20,7 +33,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "title": { "description": "The new title for the pull request", @@ -35,5 +49,24 @@ ], "type": "object" }, - "name": "update_pull_request_title" + "name": "update_pull_request_title", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 667901e623..fc31477f63 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -6,18 +6,19 @@ import ( "errors" "fmt" "net/http" + "reflect" "strconv" "strings" - "github.com/github/github-mcp-server/internal/profiler" - buffer "github.com/github/github-mcp-server/pkg/buffer" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/internal/profiler" + buffer "github.com/github/github-mcp-server/v2/pkg/buffer" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -47,7 +48,7 @@ const ( ) // 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) { +func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, *ActionsJobLogsOutput, error) { // First, get all jobs for the workflow run jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ Filter: "latest", @@ -66,41 +67,38 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo } 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, + result := &ActionsFailedJobLogsOutput{ + Message: "No failed jobs found in this workflow run", + RunID: runID, + TotalJobs: len(jobs.Jobs), } r, _ := json.Marshal(result) - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsJobLogsOutput{Failed: result}, nil } // Collect logs for all failed jobs - var logResults []map[string]any + var logResults []ActionsJobLog 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(), - } + jobResult = &ActionsJobLog{Error: &ActionsJobLogError{ + JobID: job.GetID(), JobName: 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) + 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}, + result := &ActionsFailedJobLogsOutput{ + Message: fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)), + RunID: runID, + TotalJobs: len(jobs.Jobs), + FailedJobs: len(failedJobs), + Logs: &logResults, + ReturnFormat: &ActionsLogsReturnFormat{Content: returnContent, URLs: !returnContent}, } r, err := json.Marshal(result) @@ -108,11 +106,11 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsJobLogsOutput{Failed: result}, 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) { +func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, *ActionsJobLogsOutput, 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 @@ -123,11 +121,11 @@ func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsJobLogsOutput{Single: jobResult}, 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) { +func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int, contentWindowSize int) (*ActionsJobLog, *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 { @@ -135,13 +133,7 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin } defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "job_id": jobID, - } - if jobName != "" { - result["job_name"] = jobName - } - + result := &ActionsJobLog{} 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 @@ -152,14 +144,17 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin } return nil, ghResp, 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 + result.Content = &ActionsJobLogContent{ + JobID: jobID, JobName: jobName, LogsContent: content, + Message: "Job logs content retrieved successfully", OriginalLength: 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.URL = &ActionsJobLogURL{ + JobID: jobID, JobName: jobName, LogsURL: url.String(), + Message: "Job logs are available for download", + 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 @@ -199,10 +194,11 @@ func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLi // ActionsList returns the tool and handler for listing GitHub Actions resources. func ActionsList(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := newActionsTool( ToolsetMetadataActions, mcp.Tool{ - Name: "actions_list", + Name: "actions_list", + OutputSchema: actionsListOutputSchema(), Description: t("TOOL_ACTIONS_LIST_DESCRIPTION", `Tools for listing GitHub Actions resources. Use this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run. @@ -217,12 +213,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an "method": { Type: "string", Description: "The action to perform", - Enum: []any{ - actionsMethodListWorkflows, - actionsMethodListWorkflowRuns, - actionsMethodListWorkflowJobs, - actionsMethodListWorkflowArtifacts, - }, + Enum: actionsEnumSchemas()[reflect.TypeFor[ActionsListMethod]()].Enum, }, "owner": { Type: "string", @@ -311,44 +302,22 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an "page": { Type: "number", Description: "Page number for pagination (default: 1)", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, - "per_page": { + "perPage": { Type: "number", Description: "Results per page for pagination (default: 30, max: 100)", - Minimum: jsonschema.Ptr(1.0), - Maximum: jsonschema.Ptr(100.0), + Minimum: new(1.0), + Maximum: new(100.0), }, }, Required: []string{"method", "owner", "repo"}, }, }, scopes.PublicRead(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 - } - - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - resourceID, err := OptionalParam[string](args, "resource_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ActionsListInput) (*mcp.CallToolResult, *ActionsListOutput, error) { + owner, repo, method, resourceID := args.Owner, args.Repo, args.Method, args.ResourceID + pagination := PaginationParams{Page: args.Page, PerPage: args.PerPage} client, err := deps.GetClient(ctx) if err != nil { @@ -388,10 +357,10 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an result, payload, err := listWorkflows(ctx, client, owner, repo, pagination) return attachIFC(result), payload, err case actionsMethodListWorkflowRuns: - result, payload, err := listWorkflowRuns(ctx, client, args, owner, repo, resourceID, pagination) + result, payload, err := listWorkflowRuns(ctx, client, args.WorkflowRunsFilter, owner, repo, resourceID, pagination) return attachIFC(result), payload, err case actionsMethodListWorkflowJobs: - result, payload, err := listWorkflowJobs(ctx, client, args, owner, repo, resourceIDInt, pagination) + result, payload, err := listWorkflowJobs(ctx, client, args.WorkflowJobsFilter, owner, repo, resourceIDInt, pagination) return attachIFC(result), payload, err case actionsMethodListWorkflowArtifacts: result, payload, err := listWorkflowArtifacts(ctx, client, owner, repo, resourceIDInt, pagination) @@ -400,16 +369,18 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, + normalizeActionsArguments("list"), ) return tool } // ActionsGet returns the tool and handler for getting GitHub Actions resources. func ActionsGet(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := newActionsTool( ToolsetMetadataActions, mcp.Tool{ - Name: "actions_get", + Name: "actions_get", + OutputSchema: actionsGetOutputSchema(), Description: t("TOOL_ACTIONS_GET_DESCRIPTION", `Get details about specific GitHub Actions resources. Use this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs. `), @@ -423,14 +394,7 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an "method": { Type: "string", Description: "The method to execute", - Enum: []any{ - actionsMethodGetWorkflow, - actionsMethodGetWorkflowRun, - actionsMethodGetWorkflowJob, - actionsMethodDownloadWorkflowArtifact, - actionsMethodGetWorkflowRunUsage, - actionsMethodGetWorkflowRunLogsURL, - }, + Enum: actionsEnumSchemas()[reflect.TypeFor[ActionsGetMethod]()].Enum, }, "owner": { Type: "string", @@ -454,24 +418,8 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an }, }, scopes.PublicRead(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 - } - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - resourceID, err := RequiredParam[string](args, "resource_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ActionsGetInput) (*mcp.CallToolResult, *ActionsGetOutput, error) { + owner, repo, method, resourceID := args.Owner, args.Repo, args.Method, args.ResourceID client, err := deps.GetClient(ctx) if err != nil { @@ -522,21 +470,23 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, + normalizeActionsArguments("get"), ) return tool } // ActionsRunTrigger returns the tool and handler for triggering GitHub Actions workflows. func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := newActionsTool( ToolsetMetadataActions, mcp.Tool{ - Name: "actions_run_trigger", - Description: t("TOOL_ACTIONS_RUN_TRIGGER_DESCRIPTION", "Trigger GitHub Actions workflow operations, including running, re-running, cancelling workflow runs, and deleting workflow run logs."), + Name: "actions_run_trigger", + OutputSchema: actionsRunTriggerOutputSchema(), + Description: t("TOOL_ACTIONS_RUN_TRIGGER_DESCRIPTION", "Trigger GitHub Actions workflow operations, including running, re-running, cancelling workflow runs, and deleting workflow run logs."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ACTIONS_RUN_TRIGGER_USER_TITLE", "Trigger GitHub Actions workflow actions"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), + DestructiveHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -544,13 +494,7 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo "method": { Type: "string", Description: "The method to execute", - Enum: []any{ - actionsMethodRunWorkflow, - actionsMethodRerunWorkflowRun, - actionsMethodRerunFailedJobs, - actionsMethodCancelWorkflowRun, - actionsMethodDeleteWorkflowRunLogs, - }, + Enum: actionsEnumSchemas()[reflect.TypeFor[ActionsRunTriggerMethod]()].Enum, }, "owner": { Type: "string", @@ -582,30 +526,9 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo }, }, scopes.RequireAll(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 - } - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - // Get optional parameters - workflowID, _ := OptionalParam[string](args, "workflow_id") - ref, _ := OptionalParam[string](args, "ref") - runID, _ := OptionalIntParam(args, "run_id") - - // Get optional inputs parameter - inputs, err := OptionalParam[map[string]any](args, "inputs") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ActionsRunTriggerInput) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { + owner, repo, method := args.Owner, args.Repo, args.Method + workflowID, ref, runID, inputs := args.WorkflowID, args.Ref, args.RunID, args.Inputs // Validate required parameters based on action type if method == actionsMethodRunWorkflow { @@ -639,16 +562,18 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, + normalizeActionsArguments("trigger"), ) return tool } // ActionsGetJobLogs returns the tool and handler for getting workflow job logs. func ActionsGetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := newActionsTool( ToolsetMetadataActions, mcp.Tool{ - Name: "get_job_logs", + Name: "get_job_logs", + OutputSchema: actionsJobLogsOutputSchema(), Description: t("TOOL_GET_JOB_LOGS_CONSOLIDATED_DESCRIPTION", `Get logs for GitHub Actions workflow jobs. Use this tool to retrieve logs for a specific job or all failed jobs in a workflow run. For single job logs, provide job_id. For all failed jobs in a run, provide run_id with failed_only=true. @@ -694,44 +619,9 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i }, }, scopes.PublicRead(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 - } - - 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 or invalid - if tailLines <= 0 { - tailLines = 500 - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ActionsGetJobLogsInput) (*mcp.CallToolResult, *ActionsJobLogsOutput, error) { + owner, repo, jobID, runID := args.Owner, args.Repo, args.JobID, args.RunID + failedOnly, returnContent, tailLines := args.FailedOnly, args.ReturnContent, args.TailLines client, err := deps.GetClient(ctx) if err != nil { @@ -766,13 +656,14 @@ 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 }, + normalizeActionsArguments("logs"), ) return tool } // Helper functions for consolidated actions tools -func getWorkflow(ctx context.Context, client *github.Client, owner, repo, resourceID string) (*mcp.CallToolResult, any, error) { +func getWorkflow(ctx context.Context, client *github.Client, owner, repo, resourceID string) (*mcp.CallToolResult, *ActionsGetOutput, error) { var workflow *github.Workflow var resp *github.Response var err error @@ -793,23 +684,24 @@ func getWorkflow(ctx context.Context, client *github.Client, owner, repo, resour return nil, nil, fmt.Errorf("failed to marshal workflow: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodGetWorkflow, Workflow: convertToActionsWorkflow(workflow)}, nil } -func getWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { +func getWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, *ActionsGetOutput, error) { workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, resourceID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(convertToMinimalWorkflowRun(workflowRun)) + output := convertToMinimalWorkflowRun(workflowRun) + r, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal workflow run: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodGetWorkflowRun, Run: &output}, nil } -func getWorkflowJob(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { +func getWorkflowJob(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, *ActionsGetOutput, error) { workflowJob, resp, err := client.Actions.GetWorkflowJobByID(ctx, owner, repo, resourceID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow job", resp, err), nil, nil @@ -819,10 +711,15 @@ func getWorkflowJob(ctx context.Context, client *github.Client, owner, repo stri if err != nil { return nil, nil, fmt.Errorf("failed to marshal workflow job: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + var output *MinimalWorkflowJob + if workflowJob != nil { + job := convertToMinimalWorkflowJob(workflowJob) + output = &job + } + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodGetWorkflowJob, Job: output}, nil } -func listWorkflows(ctx context.Context, client *github.Client, owner, repo string, pagination PaginationParams) (*mcp.CallToolResult, any, error) { +func listWorkflows(ctx context.Context, client *github.Client, owner, repo string, pagination PaginationParams) (*mcp.CallToolResult, *ActionsListOutput, error) { opts := &github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, @@ -839,29 +736,19 @@ func listWorkflows(ctx context.Context, client *github.Client, owner, repo strin return nil, nil, fmt.Errorf("failed to marshal workflows: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsListOutput{Method: actionsMethodListWorkflows, Workflows: convertToActionsWorkflows(workflows)}, nil } -func listWorkflowRuns(ctx context.Context, client *github.Client, args map[string]any, owner, repo, resourceID string, pagination PaginationParams) (*mcp.CallToolResult, any, error) { - filterArgs, err := OptionalParam[map[string]any](args, "workflow_runs_filter") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - filterArgsTyped := make(map[string]string) - for k, v := range filterArgs { - if strVal, ok := v.(string); ok { - filterArgsTyped[k] = strVal - } else { - filterArgsTyped[k] = "" - } +func listWorkflowRuns(ctx context.Context, client *github.Client, filter ActionsWorkflowRunsFilter, owner, repo, resourceID string, pagination PaginationParams) (*mcp.CallToolResult, *ActionsListOutput, error) { + if filter.validationError != "" { + return utils.NewToolResultError(filter.validationError), nil, nil } listWorkflowRunsOptions := &github.ListWorkflowRunsOptions{ - Actor: filterArgsTyped["actor"], - Branch: filterArgsTyped["branch"], - Event: filterArgsTyped["event"], - Status: filterArgsTyped["status"], + Actor: filter.Actor, + Branch: filter.Branch, + Event: filter.Event, + Status: filter.Status, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, @@ -870,6 +757,7 @@ func listWorkflowRuns(ctx context.Context, client *github.Client, args map[strin var workflowRuns *github.WorkflowRuns var resp *github.Response + var err error if resourceID == "" { workflowRuns, resp, err = client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, listWorkflowRunsOptions) @@ -884,31 +772,22 @@ func listWorkflowRuns(ctx context.Context, client *github.Client, args map[strin } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(convertToMinimalWorkflowRuns(workflowRuns)) + output := convertToMinimalWorkflowRuns(workflowRuns) + r, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal workflow runs: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsListOutput{Method: actionsMethodListWorkflowRuns, Runs: &output}, nil } -func listWorkflowJobs(ctx context.Context, client *github.Client, args map[string]any, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, any, error) { - filterArgs, err := OptionalParam[map[string]any](args, "workflow_jobs_filter") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - filterArgsTyped := make(map[string]string) - for k, v := range filterArgs { - if strVal, ok := v.(string); ok { - filterArgsTyped[k] = strVal - } else { - filterArgsTyped[k] = "" - } +func listWorkflowJobs(ctx context.Context, client *github.Client, filter ActionsWorkflowJobsFilter, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, *ActionsListOutput, error) { + if filter.validationError != "" { + return utils.NewToolResultError(filter.validationError), nil, nil } workflowJobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, resourceID, &github.ListWorkflowJobsOptions{ - Filter: filterArgsTyped["filter"], + Filter: filter.Filter, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, @@ -918,8 +797,8 @@ func listWorkflowJobs(ctx context.Context, client *github.Client, args map[strin return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil, nil } - response := map[string]any{ - "jobs": convertToMinimalWorkflowJobs(workflowJobs), + response := &ActionsJobsOutput{ + Jobs: convertToMinimalWorkflowJobs(workflowJobs), } defer func() { _ = resp.Body.Close() }() @@ -928,10 +807,10 @@ func listWorkflowJobs(ctx context.Context, client *github.Client, args map[strin return nil, nil, fmt.Errorf("failed to marshal workflow jobs: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsListOutput{Method: actionsMethodListWorkflowJobs, Jobs: &response.Jobs}, nil } -func listWorkflowArtifacts(ctx context.Context, client *github.Client, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, any, error) { +func listWorkflowArtifacts(ctx context.Context, client *github.Client, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, *ActionsListOutput, error) { opts := &github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, @@ -948,10 +827,10 @@ func listWorkflowArtifacts(ctx context.Context, client *github.Client, owner, re return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsListOutput{Method: actionsMethodListWorkflowArtifacts, Artifacts: convertToActionsArtifacts(artifacts)}, nil } -func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { +func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, *ActionsGetOutput, error) { // Get the download URL for the artifact url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, resourceID, 1) if err != nil { @@ -960,11 +839,11 @@ func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, 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": resourceID, + result := &ActionsArtifactDownloadOutput{ + DownloadURL: 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.", + ArtifactID: resourceID, } r, err := json.Marshal(result) @@ -972,10 +851,10 @@ func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodDownloadWorkflowArtifact, Artifact: result}, nil } -func getWorkflowRunLogsURL(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { +func getWorkflowRunLogsURL(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, *ActionsGetOutput, error) { // Get the download URL for the logs url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1) if err != nil { @@ -984,12 +863,12 @@ func getWorkflowRunLogsURL(ctx context.Context, client *github.Client, owner, re 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", + result := &ActionsRunLogsOutput{ + LogsURL: 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.", + OptimizationTip: "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) @@ -997,10 +876,10 @@ func getWorkflowRunLogsURL(ctx context.Context, client *github.Client, owner, re return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodGetWorkflowRunLogsURL, Logs: result}, nil } -func getWorkflowRunUsage(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { +func getWorkflowRunUsage(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, *ActionsGetOutput, error) { usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, resourceID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run usage", resp, err), nil, nil @@ -1012,10 +891,10 @@ func getWorkflowRunUsage(ctx context.Context, client *github.Client, owner, repo return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodGetWorkflowRunUsage, Usage: convertToActionsRunUsage(usage)}, nil } -func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workflowID, ref string, inputs map[string]any) (*mcp.CallToolResult, any, error) { +func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workflowID, ref string, inputs map[string]any) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { event := github.CreateWorkflowDispatchEventRequest{ Ref: ref, Inputs: inputs, @@ -1023,7 +902,7 @@ func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workfl var resp *github.Response var err error - var workflowType string + var workflowType ActionsWorkflowType if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { _, resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) @@ -1038,14 +917,18 @@ func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workfl } 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, + inputJSON, err := json.Marshal(inputs) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + result := &ActionsDispatchOutput{ + Message: "Workflow run has been queued", + WorkflowType: workflowType, + WorkflowID: workflowID, + Ref: ref, + Inputs: inputJSON, + Status: resp.Status, + StatusCode: resp.StatusCode, } r, err := json.Marshal(result) @@ -1053,21 +936,21 @@ func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workfl return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsRunTriggerOutput{Method: actionsMethodRunWorkflow, Dispatch: result}, nil } -func rerunWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { +func rerunWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { 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, + result := &ActionsRunOperationOutput{ + Message: "Workflow run has been queued for re-run", + RunID: runID, + Status: resp.Status, + StatusCode: resp.StatusCode, } r, err := json.Marshal(result) @@ -1075,21 +958,21 @@ func rerunWorkflowRun(ctx context.Context, client *github.Client, owner, repo st return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsRunTriggerOutput{Method: actionsMethodRerunWorkflowRun, Rerun: result}, nil } -func rerunFailedJobs(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { +func rerunFailedJobs(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { 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, + result := &ActionsRunOperationOutput{ + Message: "Failed jobs have been queued for re-run", + RunID: runID, + Status: resp.Status, + StatusCode: resp.StatusCode, } r, err := json.Marshal(result) @@ -1097,24 +980,23 @@ func rerunFailedJobs(ctx context.Context, client *github.Client, owner, repo str return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsRunTriggerOutput{Method: actionsMethodRerunFailedJobs, RerunFailed: result}, nil } -func cancelWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { +func cancelWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) if err != nil { - var acceptedErr *github.AcceptedError - if !errors.As(err, &acceptedErr) { + if _, ok := errors.AsType[*github.AcceptedError](err); !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, + result := &ActionsRunOperationOutput{ + Message: "Workflow run has been cancelled", + RunID: runID, + Status: resp.Status, + StatusCode: resp.StatusCode, } r, err := json.Marshal(result) @@ -1122,21 +1004,21 @@ func cancelWorkflowRun(ctx context.Context, client *github.Client, owner, repo s return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsRunTriggerOutput{Method: actionsMethodCancelWorkflowRun, Cancel: result}, nil } -func deleteWorkflowRunLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { +func deleteWorkflowRunLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { 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 } defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "message": "Workflow run logs have been deleted", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, + result := &ActionsRunOperationOutput{ + Message: "Workflow run logs have been deleted", + RunID: runID, + Status: resp.Status, + StatusCode: resp.StatusCode, } r, err := json.Marshal(result) @@ -1144,5 +1026,5 @@ func deleteWorkflowRunLogs(ctx context.Context, client *github.Client, owner, re return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsRunTriggerOutput{Method: actionsMethodDeleteWorkflowRunLogs, DeleteLogs: result}, nil } diff --git a/pkg/github/actions_minimal_test.go b/pkg/github/actions_minimal_test.go index 4f0f8bd977..a2d547b33f 100644 --- a/pkg/github/actions_minimal_test.go +++ b/pkg/github/actions_minimal_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/google/go-github/v89/github" + "github.com/google/go-github/v92/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -79,7 +79,7 @@ func TestConvertToMinimalWorkflowJob(t *testing.T) { func TestConvertToMinimalActionsLists(t *testing.T) { t.Run("workflow runs", func(t *testing.T) { result := convertToMinimalWorkflowRuns(&github.WorkflowRuns{ - TotalCount: github.Ptr(2), + TotalCount: new(2), WorkflowRuns: []*github.WorkflowRun{actionsTestWorkflowRun(), nil}, }) assert.Equal(t, 2, result.TotalCount) @@ -88,7 +88,7 @@ func TestConvertToMinimalActionsLists(t *testing.T) { t.Run("workflow jobs", func(t *testing.T) { result := convertToMinimalWorkflowJobs(&github.Jobs{ - TotalCount: github.Ptr(2), + TotalCount: new(2), Jobs: []*github.WorkflowJob{actionsTestWorkflowJob(), nil}, }) assert.Equal(t, 2, result.TotalCount) @@ -110,94 +110,94 @@ func TestConvertToMinimalActionsLists(t *testing.T) { func actionsTestWorkflowRun() *github.WorkflowRun { repository := &github.Repository{ - ID: github.Ptr(int64(1296269)), - NodeID: github.Ptr("MDEwOlJlcG9zaXRvcnkxMjk2MjY5"), - Name: github.Ptr("octo-repo"), - FullName: github.Ptr("octo-org/octo-repo"), - Description: github.Ptr("A representative repository description included in the full API response."), - HTMLURL: github.Ptr("https://github.com/octo-org/octo-repo"), - URL: github.Ptr("https://api.github.com/repos/octo-org/octo-repo"), - CloneURL: github.Ptr("https://github.com/octo-org/octo-repo.git"), - Language: github.Ptr("Go"), + ID: new(int64(1296269)), + NodeID: new("MDEwOlJlcG9zaXRvcnkxMjk2MjY5"), + Name: new("octo-repo"), + FullName: new("octo-org/octo-repo"), + Description: new("A representative repository description included in the full API response."), + HTMLURL: new("https://github.com/octo-org/octo-repo"), + URL: new("https://api.github.com/repos/octo-org/octo-repo"), + CloneURL: new("https://github.com/octo-org/octo-repo.git"), + Language: new("Go"), Topics: []string{"actions", "mcp", "automation"}, } return &github.WorkflowRun{ - ID: github.Ptr(int64(30433642)), - Name: github.Ptr("CI"), - NodeID: github.Ptr("MDEyOldvcmtmbG93IFJ1bjI2OTI4OQ=="), - HeadBranch: github.Ptr("feature/minimal-actions"), - HeadSHA: github.Ptr("acb5820ced9479c074f688cc328bf03f341a511d"), - Path: github.Ptr(".github/workflows/ci.yml"), - RunNumber: github.Ptr(562), - RunAttempt: github.Ptr(2), - Event: github.Ptr("pull_request"), - DisplayTitle: github.Ptr("Reduce GitHub Actions response payloads"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("failure"), - WorkflowID: github.Ptr(int64(161335)), - CheckSuiteID: github.Ptr(int64(42)), - CheckSuiteNodeID: github.Ptr("MDEwOkNoZWNrU3VpdGU0Mg=="), - URL: github.Ptr("https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642"), - HTMLURL: github.Ptr("https://github.com/octo-org/octo-repo/actions/runs/30433642"), - JobsURL: github.Ptr("https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/jobs"), - LogsURL: github.Ptr("https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/logs"), - CheckSuiteURL: github.Ptr("https://api.github.com/repos/octo-org/octo-repo/check-suites/42"), - ArtifactsURL: github.Ptr("https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/artifacts"), - CancelURL: github.Ptr("https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/cancel"), - RerunURL: github.Ptr("https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/rerun"), - PreviousAttemptURL: github.Ptr("https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/attempts/1"), - WorkflowURL: github.Ptr("https://api.github.com/repos/octo-org/octo-repo/actions/workflows/161335"), + ID: new(int64(30433642)), + Name: new("CI"), + NodeID: new("MDEyOldvcmtmbG93IFJ1bjI2OTI4OQ=="), + HeadBranch: new("feature/minimal-actions"), + HeadSHA: new("acb5820ced9479c074f688cc328bf03f341a511d"), + Path: new(".github/workflows/ci.yml"), + RunNumber: new(562), + RunAttempt: new(2), + Event: new("pull_request"), + DisplayTitle: new("Reduce GitHub Actions response payloads"), + Status: new("completed"), + Conclusion: new("failure"), + WorkflowID: new(int64(161335)), + CheckSuiteID: new(int64(42)), + CheckSuiteNodeID: new("MDEwOkNoZWNrU3VpdGU0Mg=="), + URL: new("https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642"), + HTMLURL: new("https://github.com/octo-org/octo-repo/actions/runs/30433642"), + JobsURL: new("https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/jobs"), + LogsURL: new("https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/logs"), + CheckSuiteURL: new("https://api.github.com/repos/octo-org/octo-repo/check-suites/42"), + ArtifactsURL: new("https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/artifacts"), + CancelURL: new("https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/cancel"), + RerunURL: new("https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/rerun"), + PreviousAttemptURL: new("https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642/attempts/1"), + WorkflowURL: new("https://api.github.com/repos/octo-org/octo-repo/actions/workflows/161335"), Repository: repository, HeadRepository: repository, Actor: &github.User{ - Login: github.Ptr("octocat"), - ID: github.Ptr(int64(1)), - NodeID: github.Ptr("MDQ6VXNlcjE="), - AvatarURL: github.Ptr("https://github.com/images/error/octocat_happy.gif"), - HTMLURL: github.Ptr("https://github.com/octocat"), - URL: github.Ptr("https://api.github.com/users/octocat"), - Name: github.Ptr("The Octocat"), - Bio: github.Ptr("A long biography that is not needed to identify the workflow run actor."), + Login: new("octocat"), + ID: new(int64(1)), + NodeID: new("MDQ6VXNlcjE="), + AvatarURL: new("https://github.com/images/error/octocat_happy.gif"), + HTMLURL: new("https://github.com/octocat"), + URL: new("https://api.github.com/users/octocat"), + Name: new("The Octocat"), + Bio: new("A long biography that is not needed to identify the workflow run actor."), }, TriggeringActor: &github.User{ - Login: github.Ptr("hubot"), - ID: github.Ptr(int64(2)), - HTMLURL: github.Ptr("https://github.com/hubot"), - URL: github.Ptr("https://api.github.com/users/hubot"), + Login: new("hubot"), + ID: new(int64(2)), + HTMLURL: new("https://github.com/hubot"), + URL: new("https://api.github.com/users/hubot"), }, PullRequests: []*github.PullRequest{ { - ID: github.Ptr(int64(1001)), - Number: github.Ptr(42), - Title: github.Ptr("Reduce GitHub Actions response payloads"), - Body: github.Ptr("A pull request body that is unnecessary in a workflow run response."), - HTMLURL: github.Ptr("https://github.com/octo-org/octo-repo/pull/42"), + ID: new(int64(1001)), + Number: new(42), + Title: new("Reduce GitHub Actions response payloads"), + Body: new("A pull request body that is unnecessary in a workflow run response."), + HTMLURL: new("https://github.com/octo-org/octo-repo/pull/42"), Head: &github.PullRequestBranch{ - Ref: github.Ptr("feature/minimal-actions"), - SHA: github.Ptr("acb5820ced9479c074f688cc328bf03f341a511d"), + Ref: new("feature/minimal-actions"), + SHA: new("acb5820ced9479c074f688cc328bf03f341a511d"), Repo: repository, }, Base: &github.PullRequestBranch{ - Ref: github.Ptr("main"), - SHA: github.Ptr("9a2f3ec"), + Ref: new("main"), + SHA: new("9a2f3ec"), Repo: repository, }, }, }, HeadCommit: &github.HeadCommit{ - Message: github.Ptr("Reduce GitHub Actions response payloads"), - URL: github.Ptr("https://api.github.com/repos/octo-org/octo-repo/commits/acb5820"), + Message: new("Reduce GitHub Actions response payloads"), + URL: new("https://api.github.com/repos/octo-org/octo-repo/commits/acb5820"), Author: &github.CommitAuthor{ - Name: github.Ptr("The Octocat"), - Email: github.Ptr("octocat@example.com"), + Name: new("The Octocat"), + Email: new("octocat@example.com"), }, }, ReferencedWorkflows: []*github.ReferencedWorkflow{ { - Path: github.Ptr(".github/workflows/reusable-tests.yml"), - SHA: github.Ptr("9f4f87d9790ab0f5c2c5ad2b74b886cab515a886"), - Ref: github.Ptr("refs/tags/v3"), + Path: new(".github/workflows/reusable-tests.yml"), + SHA: new("9f4f87d9790ab0f5c2c5ad2b74b886cab515a886"), + Ref: new("refs/tags/v3"), }, nil, }, @@ -209,42 +209,42 @@ func actionsTestWorkflowRun() *github.WorkflowRun { func actionsTestWorkflowJob() *github.WorkflowJob { return &github.WorkflowJob{ - ID: github.Ptr(int64(399444496)), - RunID: github.Ptr(int64(30433642)), - RunURL: github.Ptr("https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642"), - NodeID: github.Ptr("MDEyOldvcmtmbG93IEpvYjM5OTQ0NDQ5Ng=="), - HeadBranch: github.Ptr("feature/minimal-actions"), - HeadSHA: github.Ptr("acb5820ced9479c074f688cc328bf03f341a511d"), - URL: github.Ptr("https://api.github.com/repos/octo-org/octo-repo/actions/jobs/399444496"), - HTMLURL: github.Ptr("https://github.com/octo-org/octo-repo/runs/399444496"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("failure"), + ID: new(int64(399444496)), + RunID: new(int64(30433642)), + RunURL: new("https://api.github.com/repos/octo-org/octo-repo/actions/runs/30433642"), + NodeID: new("MDEyOldvcmtmbG93IEpvYjM5OTQ0NDQ5Ng=="), + HeadBranch: new("feature/minimal-actions"), + HeadSHA: new("acb5820ced9479c074f688cc328bf03f341a511d"), + URL: new("https://api.github.com/repos/octo-org/octo-repo/actions/jobs/399444496"), + HTMLURL: new("https://github.com/octo-org/octo-repo/runs/399444496"), + Status: new("completed"), + Conclusion: new("failure"), CreatedAt: actionsTestTimestamp(), StartedAt: actionsTestTimestamp(), CompletedAt: actionsTestTimestamp(), - Name: github.Ptr("test (ubuntu-latest, Go 1.24)"), - CheckRunURL: github.Ptr("https://api.github.com/repos/octo-org/octo-repo/check-runs/399444496"), + Name: new("test (ubuntu-latest, Go 1.24)"), + CheckRunURL: new("https://api.github.com/repos/octo-org/octo-repo/check-runs/399444496"), Labels: []string{"ubuntu-latest", "x64"}, - RunnerID: github.Ptr(int64(1)), - RunnerName: github.Ptr("GitHub Actions 1"), - RunnerGroupID: github.Ptr(int64(2)), - RunnerGroupName: github.Ptr("GitHub Actions"), - RunAttempt: github.Ptr(int64(2)), - WorkflowName: github.Ptr("CI"), + RunnerID: new(int64(1)), + RunnerName: new("GitHub Actions 1"), + RunnerGroupID: new(int64(2)), + RunnerGroupName: new("GitHub Actions"), + RunAttempt: new(int64(2)), + WorkflowName: new("CI"), Steps: []*github.TaskStep{ { - Name: github.Ptr("Set up job"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - Number: github.Ptr(int64(1)), + Name: new("Set up job"), + Status: new("completed"), + Conclusion: new("success"), + Number: new(int64(1)), StartedAt: actionsTestTimestamp(), CompletedAt: actionsTestTimestamp(), }, { - Name: github.Ptr("Run tests"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("failure"), - Number: github.Ptr(int64(2)), + Name: new("Run tests"), + Status: new("completed"), + Conclusion: new("failure"), + Number: new(int64(2)), StartedAt: actionsTestTimestamp(), CompletedAt: actionsTestTimestamp(), }, diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 964bc95a6b..cee001c804 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -7,9 +7,9 @@ import ( "net/http/httptest" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -46,19 +46,19 @@ func Test_ActionsList_ListWorkflows(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposActionsWorkflowsByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { workflows := &github.Workflows{ - TotalCount: github.Ptr(2), + TotalCount: new(2), Workflows: []*github.Workflow{ { - ID: github.Ptr(int64(1)), - Name: github.Ptr("CI"), - Path: github.Ptr(".github/workflows/ci.yml"), - State: github.Ptr("active"), + ID: new(int64(1)), + Name: new("CI"), + Path: new(".github/workflows/ci.yml"), + State: new("active"), }, { - ID: github.Ptr(int64(2)), - Name: github.Ptr("Deploy"), - Path: github.Ptr(".github/workflows/deploy.yml"), - State: github.Ptr("active"), + ID: new(int64(2)), + Name: new("Deploy"), + Path: new(".github/workflows/deploy.yml"), + State: new("active"), }, }, } @@ -122,13 +122,13 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { runs := &github.WorkflowRuns{ - TotalCount: github.Ptr(1), + TotalCount: new(1), WorkflowRuns: []*github.WorkflowRun{ { - ID: github.Ptr(int64(123)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), + ID: new(int64(123)), + Name: new("CI"), + Status: new("completed"), + Conclusion: new("success"), }, }, } @@ -167,18 +167,18 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposActionsRunsByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { runs := &github.WorkflowRuns{ - TotalCount: github.Ptr(2), + TotalCount: new(2), WorkflowRuns: []*github.WorkflowRun{ { - ID: github.Ptr(int64(123)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), + ID: new(int64(123)), + Name: new("CI"), + Status: new("completed"), + Conclusion: new("success"), }, { - ID: github.Ptr(int64(456)), - Name: github.Ptr("Deploy"), - Status: github.Ptr("in_progress"), + ID: new(int64(456)), + Name: new("Deploy"), + Status: new("in_progress"), Conclusion: nil, }, }, @@ -217,7 +217,7 @@ func Test_ActionsList_ListWorkflowJobs(t *testing.T) { toolDef := ActionsList(translations.NullTranslationHelper) mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposActionsRunsJobsByOwnerByRepoByRunID: mockResponse(t, http.StatusOK, &github.Jobs{ - TotalCount: github.Ptr(1), + TotalCount: new(1), Jobs: []*github.WorkflowJob{actionsTestWorkflowJob()}, }), }) @@ -268,10 +268,10 @@ func Test_ActionsGet_GetWorkflow(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposActionsWorkflowsByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { workflow := &github.Workflow{ - ID: github.Ptr(int64(1)), - Name: github.Ptr("CI"), - Path: github.Ptr(".github/workflows/ci.yml"), - State: github.Ptr("active"), + ID: new(int64(1)), + Name: new("CI"), + Path: new(".github/workflows/ci.yml"), + State: new("active"), } w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(workflow) @@ -571,10 +571,8 @@ func Test_ActionsGetJobLogs(t *testing.T) { // Verify tool definition once toolDef := ActionsGetJobLogs(translations.NullTranslationHelper) - // Note: consolidated ActionsGetJobLogs has same tool name "get_job_logs" as the individual tool - // but with different descriptions. We skip toolsnap validation here since the individual - // tool's toolsnap already exists and is tested in Test_GetJobLogs. - // The consolidated tool has FeatureFlagEnable set, so only one will be active at a time. + // The legacy individual tool has a different contract under the same name. + require.NoError(t, toolsnaps.Test("get_job_logs_actions", toolDef.Tool)) assert.Equal(t, "get_job_logs", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) @@ -651,22 +649,22 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { jobs := &github.Jobs{ - TotalCount: github.Ptr(3), + TotalCount: new(3), Jobs: []*github.WorkflowJob{ { - ID: github.Ptr(int64(1)), - Name: github.Ptr("test-job-1"), - Conclusion: github.Ptr("success"), + ID: new(int64(1)), + Name: new("test-job-1"), + Conclusion: new("success"), }, { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test-job-2"), - Conclusion: github.Ptr("failure"), + ID: new(int64(2)), + Name: new("test-job-2"), + Conclusion: new("failure"), }, { - ID: github.Ptr(int64(3)), - Name: github.Ptr("test-job-3"), - Conclusion: github.Ptr("failure"), + ID: new(int64(3)), + Name: new("test-job-3"), + Conclusion: new("failure"), }, }, } @@ -710,17 +708,17 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { jobs := &github.Jobs{ - TotalCount: github.Ptr(2), + TotalCount: new(2), Jobs: []*github.WorkflowJob{ { - ID: github.Ptr(int64(1)), - Name: github.Ptr("test-job-1"), - Conclusion: github.Ptr("success"), + ID: new(int64(1)), + Name: new("test-job-1"), + Conclusion: new("success"), }, { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test-job-2"), - Conclusion: github.Ptr("success"), + ID: new(int64(2)), + Name: new("test-job-2"), + Conclusion: new("success"), }, }, } diff --git a/pkg/github/actions_types.go b/pkg/github/actions_types.go new file mode 100644 index 0000000000..ffdd689b00 --- /dev/null +++ b/pkg/github/actions_types.go @@ -0,0 +1,661 @@ +package github + +import ( + "context" + "encoding/json" + "reflect" + "slices" + "strings" + "sync" + + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type ActionsListMethod string +type ActionsGetMethod string +type ActionsRunTriggerMethod string +type ActionsWorkflowState string +type ActionsWorkflowType string + +var actionsEnumSchemas = sync.OnceValue(func() map[reflect.Type]*jsonschema.Schema { + return map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[ActionsListMethod](): {Type: "string", Enum: []any{ + actionsMethodListWorkflows, actionsMethodListWorkflowRuns, + actionsMethodListWorkflowJobs, actionsMethodListWorkflowArtifacts, + }}, + reflect.TypeFor[ActionsGetMethod](): {Type: "string", Enum: []any{ + actionsMethodGetWorkflow, actionsMethodGetWorkflowRun, actionsMethodGetWorkflowJob, + actionsMethodDownloadWorkflowArtifact, actionsMethodGetWorkflowRunUsage, actionsMethodGetWorkflowRunLogsURL, + }}, + reflect.TypeFor[ActionsRunTriggerMethod](): {Type: "string", Enum: []any{ + actionsMethodRunWorkflow, actionsMethodRerunWorkflowRun, actionsMethodRerunFailedJobs, + actionsMethodCancelWorkflowRun, actionsMethodDeleteWorkflowRunLogs, + }}, + reflect.TypeFor[ActionsWorkflowState](): {Type: "string", Enum: []any{ + "active", "deleted", "disabled_fork", "disabled_inactivity", "disabled_manually", + }}, + reflect.TypeFor[ActionsWorkflowType](): {Type: "string", Enum: []any{"workflow_file", "workflow_id"}}, + } +}) + +type ActionsListInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + ResourceID string `json:"resource_id,omitempty"` + Page int `json:"page,omitempty"` + PerPage int `json:"perPage,omitempty"` + WorkflowRunsFilter ActionsWorkflowRunsFilter `json:"workflow_runs_filter"` + WorkflowJobsFilter ActionsWorkflowJobsFilter `json:"workflow_jobs_filter"` +} + +type ActionsWorkflowRunsFilter struct { + Actor string `json:"actor,omitempty"` + Branch string `json:"branch,omitempty"` + Event string `json:"event,omitempty"` + Status string `json:"status,omitempty"` + validationError string +} + +type ActionsWorkflowJobsFilter struct { + Filter string `json:"filter,omitempty"` + validationError string +} + +// The legacy handler validates filters only after client acquisition and +// resource-ID validation. Retain that ordering even for non-object filters. +func (filter *ActionsWorkflowRunsFilter) UnmarshalJSON(raw []byte) error { + type fields ActionsWorkflowRunsFilter + *filter = ActionsWorkflowRunsFilter{} + message, err := decodeActionsFilter(raw, "workflow_runs_filter", (*fields)(filter)) + filter.validationError = message + return err +} + +func (filter *ActionsWorkflowJobsFilter) UnmarshalJSON(raw []byte) error { + type fields ActionsWorkflowJobsFilter + *filter = ActionsWorkflowJobsFilter{} + message, err := decodeActionsFilter(raw, "workflow_jobs_filter", (*fields)(filter)) + filter.validationError = message + return err +} + +func decodeActionsFilter(raw []byte, field string, output any) (string, error) { + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return "", err + } + if _, err := OptionalParam[map[string]any](map[string]any{field: value}, field); err != nil { + return err.Error(), nil + } + return "", json.Unmarshal(raw, output) +} + +func newActionsTool[In, Out any]( + toolset inventory.ToolsetMetadata, + tool mcp.Tool, + scopeAccess inventory.ScopeAccess, + handler func(context.Context, ToolDependencies, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error), + normalizers ...inventory.InputNormalizer, +) inventory.ServerTool { + cached, _ := actionsValidationSchemas.LoadOrStore(tool.Name, sync.OnceValue(func() *jsonschema.Schema { + return actionsValidationSchema(tool.InputSchema) + })) + validation := cached.(func() *jsonschema.Schema)() + return NewToolWithSchemaOptions(toolset, tool, scopeAccess, + inventory.TypedSchemaOptions{ValidationInputSchema: validation}, handler, normalizers...) +} + +var actionsValidationSchemas sync.Map + +func actionsValidationSchema(inputSchema any) *jsonschema.Schema { + raw, err := json.Marshal(inputSchema) + if err != nil { + panic(err) + } + var schema jsonschema.Schema + if err := json.Unmarshal(raw, &schema); err != nil { + panic(err) + } + // The legacy raw handler treated enums and pagination bounds as metadata. + // Relax only SDK validation; leave the advertised contract unchanged. + for _, field := range []string{"method", "workflow_runs_filter", "workflow_jobs_filter"} { + property := schema.Properties[field] + if property == nil { + continue + } + if field == "method" { + property.Enum = nil + continue + } + for _, nested := range property.Properties { + nested.Enum = nil + } + schema.Properties[field] = &jsonschema.Schema{ + Description: property.Description, + AnyOf: []*jsonschema.Schema{ + property, + {Not: &jsonschema.Schema{Type: "object"}}, + }, + } + } + for _, field := range []string{"page", "perPage"} { + if property := schema.Properties[field]; property != nil { + property.Minimum, property.Maximum = nil, nil + } + } + return &schema +} + +type ActionsGetInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + ResourceID string `json:"resource_id"` +} + +type ActionsRunTriggerInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + WorkflowID string `json:"workflow_id,omitempty"` + Ref string `json:"ref,omitempty"` + RunID int `json:"run_id,omitempty"` + Inputs map[string]any `json:"inputs,omitempty"` +} + +type ActionsGetJobLogsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + JobID int `json:"job_id,omitempty"` + RunID int `json:"run_id,omitempty"` + FailedOnly bool `json:"failed_only,omitempty"` + ReturnContent bool `json:"return_content,omitempty"` + TailLines int `json:"tail_lines,omitempty"` +} + +// Method is omitted only on SDK-validated error zero values, which the shared +// output middleware removes. Every successful handler sets its discriminant. +type ActionsListOutput struct { + Method ActionsListMethod `json:"method,omitempty"` + Workflows *ActionsWorkflowsOutput `json:"workflows,omitempty"` + Runs *MinimalWorkflowRunsResult `json:"workflow_runs,omitempty"` + Jobs *MinimalWorkflowJobsResult `json:"workflow_jobs,omitempty"` + Artifacts *ActionsArtifactsOutput `json:"artifacts,omitempty"` +} + +type ActionsWorkflow struct { + ID *int64 `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Path *string `json:"path,omitempty"` + State *ActionsWorkflowState `json:"state,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type ActionsWorkflowsOutput struct { + TotalCount *int `json:"total_count,omitempty"` + Workflows []*ActionsWorkflow `json:"workflows,omitempty"` +} + +type ActionsArtifact struct { + ID *int64 `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + SizeInBytes *int64 `json:"size_in_bytes,omitempty"` + Expired *bool `json:"expired,omitempty"` + Digest *string `json:"digest,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + WorkflowRun *ActionsArtifactWorkflowRun `json:"workflow_run,omitempty"` +} + +type ActionsArtifactWorkflowRun struct { + ID *int64 `json:"id,omitempty"` + HeadBranch *string `json:"head_branch,omitempty"` + HeadSHA *string `json:"head_sha,omitempty"` +} + +type ActionsArtifactsOutput struct { + TotalCount *int64 `json:"total_count,omitempty"` + Artifacts []*ActionsArtifact `json:"artifacts,omitempty"` +} + +type ActionsJobsOutput struct { + Jobs MinimalWorkflowJobsResult `json:"jobs"` +} + +var actionsListOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := actionsOutputSchema[ActionsListOutput]() + constrainMethodOutput(schema, map[string]string{ + actionsMethodListWorkflows: "workflows", actionsMethodListWorkflowRuns: "workflow_runs", + actionsMethodListWorkflowJobs: "workflow_jobs", actionsMethodListWorkflowArtifacts: "artifacts", + }, "workflows", "artifacts") + return schema +}) + +type ActionsGetOutput struct { + Method ActionsGetMethod `json:"method,omitempty"` + Workflow *ActionsWorkflow `json:"workflow,omitempty"` + Run *MinimalWorkflowRun `json:"workflow_run,omitempty"` + Job *MinimalWorkflowJob `json:"workflow_job,omitempty"` + Usage *ActionsRunUsageOutput `json:"usage,omitempty"` + Artifact *ActionsArtifactDownloadOutput `json:"artifact,omitempty"` + Logs *ActionsRunLogsOutput `json:"logs,omitempty"` +} + +type ActionsRunUsageOutput struct { + Billable []ActionsRunnerUsage `json:"billable,omitempty"` + RunDurationMS *int64 `json:"run_duration_ms,omitempty"` +} + +type ActionsRunnerUsage struct { + Runner string `json:"runner"` + TotalMS *int64 `json:"total_ms,omitempty"` + Jobs *int `json:"jobs,omitempty"` + JobRuns []ActionsJobRunUsage `json:"job_runs,omitempty"` +} + +type ActionsJobRunUsage struct { + JobID *int `json:"job_id,omitempty"` + DurationMS *int64 `json:"duration_ms,omitempty"` +} + +func convertToActionsWorkflow(workflow *github.Workflow) *ActionsWorkflow { + if workflow == nil { + return nil + } + var state *ActionsWorkflowState + if workflow.State != nil { + state = new(ActionsWorkflowState(*workflow.State)) + } + return &ActionsWorkflow{ + ID: workflow.ID, Name: workflow.Name, Path: workflow.Path, State: state, + HTMLURL: workflow.HTMLURL, CreatedAt: formatMinimalTimestamp(workflow.CreatedAt), + UpdatedAt: formatMinimalTimestamp(workflow.UpdatedAt), + } +} + +func convertToActionsWorkflows(workflows *github.Workflows) *ActionsWorkflowsOutput { + if workflows == nil { + return nil + } + out := &ActionsWorkflowsOutput{TotalCount: workflows.TotalCount} + for _, workflow := range workflows.Workflows { + out.Workflows = append(out.Workflows, convertToActionsWorkflow(workflow)) + } + return out +} + +func convertToActionsArtifacts(artifacts *github.ArtifactList) *ActionsArtifactsOutput { + if artifacts == nil { + return nil + } + out := &ActionsArtifactsOutput{TotalCount: artifacts.TotalCount} + for _, artifact := range artifacts.Artifacts { + if artifact == nil { + out.Artifacts = append(out.Artifacts, nil) + continue + } + item := &ActionsArtifact{ + ID: artifact.ID, Name: artifact.Name, SizeInBytes: artifact.SizeInBytes, + Expired: artifact.Expired, Digest: artifact.Digest, + CreatedAt: formatMinimalTimestamp(artifact.CreatedAt), + UpdatedAt: formatMinimalTimestamp(artifact.UpdatedAt), + ExpiresAt: formatMinimalTimestamp(artifact.ExpiresAt), + } + if run := artifact.WorkflowRun; run != nil { + item.WorkflowRun = &ActionsArtifactWorkflowRun{ + ID: run.ID, HeadBranch: run.HeadBranch, HeadSHA: run.HeadSHA, + } + } + out.Artifacts = append(out.Artifacts, item) + } + return out +} + +func convertToActionsRunUsage(usage *github.WorkflowRunUsage) *ActionsRunUsageOutput { + if usage == nil { + return nil + } + out := &ActionsRunUsageOutput{RunDurationMS: usage.RunDurationMS} + if usage.Billable == nil { + return out + } + for runner, bill := range *usage.Billable { + item := ActionsRunnerUsage{Runner: runner} + if bill != nil { + item.TotalMS, item.Jobs = bill.TotalMS, bill.Jobs + for _, job := range bill.JobRuns { + if job != nil { + item.JobRuns = append(item.JobRuns, ActionsJobRunUsage{ + JobID: job.JobID, DurationMS: job.DurationMS, + }) + } + } + } + out.Billable = append(out.Billable, item) + } + slices.SortFunc(out.Billable, func(a, b ActionsRunnerUsage) int { + return strings.Compare(a.Runner, b.Runner) + }) + return out +} + +type ActionsArtifactDownloadOutput struct { + ArtifactID int64 `json:"artifact_id"` + DownloadURL string `json:"download_url"` + Message string `json:"message"` + Note string `json:"note"` +} + +type ActionsRunLogsOutput struct { + LogsURL string `json:"logs_url"` + Message string `json:"message"` + Note string `json:"note"` + OptimizationTip string `json:"optimization_tip"` + Warning string `json:"warning"` +} + +var actionsGetOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := actionsOutputSchema[ActionsGetOutput]() + constrainMethodOutput(schema, map[string]string{ + actionsMethodGetWorkflow: "workflow", actionsMethodGetWorkflowRun: "workflow_run", + actionsMethodGetWorkflowJob: "workflow_job", actionsMethodGetWorkflowRunUsage: "usage", + actionsMethodDownloadWorkflowArtifact: "artifact", actionsMethodGetWorkflowRunLogsURL: "logs", + }, "workflow", "workflow_job", "usage") + return schema +}) + +type ActionsRunTriggerOutput struct { + Method ActionsRunTriggerMethod `json:"method,omitempty"` + Dispatch *ActionsDispatchOutput `json:"dispatch,omitempty"` + Rerun *ActionsRunOperationOutput `json:"rerun,omitempty"` + RerunFailed *ActionsRunOperationOutput `json:"rerun_failed,omitempty"` + Cancel *ActionsRunOperationOutput `json:"cancel,omitempty"` + DeleteLogs *ActionsRunOperationOutput `json:"delete_logs,omitempty"` +} + +// Workflow dispatch inputs are arbitrary user JSON, echoed verbatim by the +// legacy response. This is the only open-ended output field. +type ActionsDispatchOutput struct { + Inputs json.RawMessage `json:"inputs"` + Message string `json:"message"` + Ref string `json:"ref"` + Status string `json:"status"` + StatusCode int `json:"status_code"` + WorkflowID string `json:"workflow_id"` + WorkflowType ActionsWorkflowType `json:"workflow_type"` +} + +type ActionsRunOperationOutput struct { + Message string `json:"message"` + RunID int64 `json:"run_id"` + Status string `json:"status"` + StatusCode int `json:"status_code"` +} + +var actionsRunTriggerOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := actionsOutputSchema[ActionsRunTriggerOutput]() + dispatch := schema.Properties["dispatch"] + dispatch.Properties["inputs"] = &jsonschema.Schema{ + Types: []string{"object", "null"}, + AdditionalProperties: &jsonschema.Schema{}, + } + schema.Defs["run_operation"] = actionsOutputSchema[ActionsRunOperationOutput]() + for _, field := range []string{"rerun", "rerun_failed", "cancel", "delete_logs"} { + schema.Properties[field] = &jsonschema.Schema{Ref: "#/$defs/run_operation"} + } + constrainMethodOutput(schema, map[string]string{ + actionsMethodRunWorkflow: "dispatch", actionsMethodRerunWorkflowRun: "rerun", + actionsMethodRerunFailedJobs: "rerun_failed", actionsMethodCancelWorkflowRun: "cancel", + actionsMethodDeleteWorkflowRunLogs: "delete_logs", + }) + return schema +}) + +type ActionsJobLogsOutput struct { + Single *ActionsJobLog + Failed *ActionsFailedJobLogsOutput +} + +type ActionsJobLog struct { + Content *ActionsJobLogContent + URL *ActionsJobLogURL + Error *ActionsJobLogError +} + +type ActionsJobLogContent struct { + JobID int64 `json:"job_id"` + JobName string `json:"job_name,omitempty"` + LogsContent string `json:"logs_content"` + Message string `json:"message"` + OriginalLength int `json:"original_length"` +} + +type ActionsJobLogURL struct { + JobID int64 `json:"job_id"` + JobName string `json:"job_name,omitempty"` + LogsURL string `json:"logs_url"` + Message string `json:"message"` + Note string `json:"note"` +} + +type ActionsJobLogError struct { + Error string `json:"error"` + JobID int64 `json:"job_id"` + JobName string `json:"job_name"` +} + +func (out ActionsJobLog) MarshalJSON() ([]byte, error) { + switch { + case out.Content != nil: + return json.Marshal(out.Content) + case out.URL != nil: + return json.Marshal(out.URL) + default: + return json.Marshal(out.Error) + } +} + +type ActionsFailedJobLogsOutput struct { + FailedJobs int `json:"failed_jobs"` + Logs *[]ActionsJobLog `json:"logs,omitempty"` + Message string `json:"message"` + ReturnFormat *ActionsLogsReturnFormat `json:"return_format,omitempty"` + RunID int64 `json:"run_id"` + TotalJobs int `json:"total_jobs"` +} + +type ActionsLogsReturnFormat struct { + Content bool `json:"content"` + URLs bool `json:"urls"` +} + +func (out ActionsJobLogsOutput) MarshalJSON() ([]byte, error) { + if out.Single != nil { + return json.Marshal(out.Single) + } + return json.Marshal(out.Failed) +} + +func actionsJobLogSchema() *jsonschema.Schema { + return repositoryUnionSchema( + actionsOutputSchema[ActionsJobLogContent](), + actionsOutputSchema[ActionsJobLogURL](), + actionsOutputSchema[ActionsJobLogError](), + ) +} + +var actionsJobLogsOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + failed := actionsOutputSchema[ActionsFailedJobLogsOutput]() + failed.Properties["logs"].Items = actionsJobLogSchema() + return repositoryUnionSchema( + &jsonschema.Schema{Type: "null"}, + actionsOutputSchema[ActionsJobLogContent](), + actionsOutputSchema[ActionsJobLogURL](), + failed, + ) +}) + +func actionsOutputSchema[T any]() *jsonschema.Schema { + schema, err := jsonschema.For[T](&jsonschema.ForOptions{TypeSchemas: actionsEnumSchemas()}) + if err != nil { + panic(err) + } + if schema.Defs == nil { + schema.Defs = make(map[string]*jsonschema.Schema) + } + describeActionsOutput(schema) + return schema +} + +func describeActionsOutput(schema *jsonschema.Schema) { + if schema == nil { + return + } + for name, property := range schema.Properties { + switch { + case strings.HasSuffix(name, "_at"): + property.Description = "Timestamp in RFC3339 format." + property.Format = "date-time" + case strings.HasSuffix(name, "_ms"): + property.Description = "Duration in milliseconds." + case name == "size_in_bytes": + property.Description = "Artifact size in bytes." + case name == "original_length": + property.Description = "Number of log lines before truncation." + case name == "state": + property.Description = "Workflow state, e.g. active, deleted, disabled_fork, disabled_inactivity, disabled_manually." + case name == "conclusion": + property.Description = "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required." + property.Enum = inventory.EnumSchema(WorkflowConclusionValues()...).Enum + case name == "status" && schema.Properties["status_code"] != nil: + property.Description = "HTTP response status." + case name == "status": + property.Description = "Lifecycle status, e.g. queued, in_progress, completed, waiting." + // Sparse legacy minimal DTOs serialize an unknown status as "". + property.Enum = inventory.EnumSchema(append(WorkflowStatusValues(), "")...).Enum + case name == "method": + property.Description = "Operation that produced this response." + case name == "runner": + property.Description = "Runner environment name; custom environments are supported." + case name == "html_url": + property.Description = "Browser URL." + } + describeActionsOutput(property) + } + describeActionsOutput(schema.Items) + for _, variant := range schema.AnyOf { + describeActionsOutput(variant) + } + for _, variant := range schema.OneOf { + describeActionsOutput(variant) + } +} + +// Normalize only fields inspected by each legacy method. Ignored optional +// trigger values and non-string filter values retain their legacy defaults. +func normalizeActionsArguments(kind string) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, &inventory.ToolInputError{Message: "invalid arguments: " + err.Error()} + } + if args == nil { + return nil, &inventory.ToolInputError{Message: "invalid arguments: arguments must be a JSON object"} + } + if err := normalizeActionsFields(args, kind); err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + return json.Marshal(args) + } +} + +func normalizeActionsFields(args map[string]any, kind string) error { + for _, field := range []string{"owner", "repo"} { + if _, err := RequiredParam[string](args, field); err != nil { + return err + } + } + if kind != "logs" { + if _, err := RequiredParam[string](args, "method"); err != nil { + return err + } + } + switch kind { + case "get": + _, err := RequiredParam[string](args, "resource_id") + return err + case "list": + if _, err := OptionalParam[string](args, "resource_id"); err != nil { + return err + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return err + } + args["page"], args["perPage"] = pagination.Page, pagination.PerPage + method := args["method"] + for _, field := range []string{"workflow_runs_filter", "workflow_jobs_filter"} { + relevant := field == "workflow_runs_filter" && method == actionsMethodListWorkflowRuns || + field == "workflow_jobs_filter" && method == actionsMethodListWorkflowJobs + if !relevant { + delete(args, field) + continue + } + filter, err := OptionalParam[map[string]any](args, field) + if err != nil { + // The typed filter retains the error until its method runs. + continue + } + if filter == nil { + delete(args, field) + continue + } + for key, value := range filter { + if _, ok := value.(string); !ok { + filter[key] = "" + } + } + } + case "trigger": + for _, field := range []string{"workflow_id", "ref"} { + if _, ok := args[field].(string); !ok { + delete(args, field) + } + } + runID, _ := OptionalIntParam(args, "run_id") + args["run_id"] = runID + _, err := OptionalParam[map[string]any](args, "inputs") + return err + case "logs": + for _, field := range []string{"job_id", "run_id"} { + value, err := OptionalIntParam(args, field) + if err != nil { + return err + } + args[field] = value + } + for _, field := range []string{"failed_only", "return_content"} { + if _, err := OptionalParam[bool](args, field); err != nil { + return err + } + } + tailLines, err := OptionalIntParam(args, "tail_lines") + if err != nil { + return err + } + if tailLines <= 0 { + tailLines = 500 + } + args["tail_lines"] = tailLines + default: + panic("unknown Actions argument kind: " + kind) + } + return nil +} diff --git a/pkg/github/code_quality.go b/pkg/github/code_quality.go index 78ba24d35d..e5ef82c394 100644 --- a/pkg/github/code_quality.go +++ b/pkg/github/code_quality.go @@ -10,15 +10,15 @@ import ( "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" ) func GetCodeQualityFinding(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetCodeQualityFindingInput, *CodeQualityFindingOutput]( ToolsetMetadataCodeQuality, mcp.Tool{ Name: "get_code_quality_finding", @@ -47,18 +47,15 @@ func GetCodeQualityFinding(t translations.TranslationHelperFunc) inventory.Serve }, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GetCodeQualityFindingInput) (*mcp.CallToolResult, *CodeQualityFindingOutput, error) { + if args.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - findingNumber, err := RequiredInt(args, "findingNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.FindingNumber == 0 { + return utils.NewToolResultError("missing required parameter: findingNumber"), nil, nil } client, err := deps.GetClient(ctx) @@ -66,15 +63,14 @@ func GetCodeQualityFinding(t translations.TranslationHelperFunc) inventory.Serve return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - apiURL := fmt.Sprintf("repos/%s/%s/code-quality/findings/%d", owner, repo, findingNumber) + apiURL := fmt.Sprintf("repos/%s/%s/code-quality/findings/%d", args.Owner, args.Repo, args.FindingNumber) req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) if err != nil { return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil } - finding := make(map[string]any) - - resp, err := client.Do(req, &finding) + var rawFinding json.RawMessage + resp, err := client.Do(req, &rawFinding) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get finding", resp, err), nil, nil } @@ -88,12 +84,26 @@ func GetCodeQualityFinding(t translations.TranslationHelperFunc) inventory.Serve return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get finding", resp, body), nil, nil } - r, err := json.Marshal(finding) + var finding CodeQualityFindingOutput + if err := json.Unmarshal(rawFinding, &finding); err != nil { + return utils.NewToolResultErrorFromErr("failed to decode finding", err), nil, nil + } + r, err := marshalLegacyCodeQualityFinding(rawFinding) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal finding", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &finding, nil }, + normalizeSecurityIntegerArguments("findingNumber"), ) } + +func marshalLegacyCodeQualityFinding(rawFinding json.RawMessage) ([]byte, error) { + // Match main's map-based formatting, including key ordering and number encoding. + var finding map[string]any + if err := json.Unmarshal(rawFinding, &finding); err != nil { + return nil, err + } + return json.Marshal(finding) +} diff --git a/pkg/github/code_quality_test.go b/pkg/github/code_quality_test.go index e84e1c5e33..0542cd2f53 100644 --- a/pkg/github/code_quality_test.go +++ b/pkg/github/code_quality_test.go @@ -6,19 +6,18 @@ import ( "net/http" "testing" - "github.com/google/go-github/v89/github" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/translations" ) func Test_GetCodeQualityFinding(t *testing.T) { // Verify tool definition once toolDef := GetCodeQualityFinding(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + testSecurityToolSnapshot(t, toolDef.Tool) assert.Equal(t, "get_code_quality_finding", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) @@ -65,11 +64,11 @@ func Test_GetCodeQualityFinding(t *testing.T) { // Setup mock finding for success case mockFinding := &codeQualityFinding{ - Number: github.Ptr(42), - State: github.Ptr("open"), + Number: new(42), + State: new("open"), Rule: &codeQualityRule{ - ID: github.Ptr("test-rule"), - Description: github.Ptr("Test Rule Description"), + ID: new("test-rule"), + Description: new("Test Rule Description"), }, } diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index 12b0b3f1e2..6d0ef073b5 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -6,19 +6,19 @@ import ( "io" "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/v89/github" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetSecurityAlertInput, *CodeScanningAlertOutput]( ToolsetMetadataCodeSecurity, mcp.Tool{ Name: "get_code_scanning_alert", @@ -47,18 +47,15 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server }, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GetSecurityAlertInput) (*mcp.CallToolResult, *CodeScanningAlertOutput, error) { + if args.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - alertNumber, err := RequiredInt(args, "alertNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.AlertNumber == 0 { + return utils.NewToolResultError("missing required parameter: alertNumber"), nil, nil } client, err := deps.GetClient(ctx) @@ -66,7 +63,7 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - alert, resp, err := client.CodeScanning.GetAlert(ctx, owner, repo, int64(alertNumber)) + alert, resp, err := client.CodeScanning.GetAlert(ctx, args.Owner, args.Repo, int64(args.AlertNumber)) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get alert", @@ -94,8 +91,9 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server // visibility and embed attacker-influenceable code snippets, so the // label is always private-untrusted. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) - return result, nil, nil + return result, codeScanningAlertOutput(alert), nil }, + normalizeSecurityIntegerArguments("alertNumber"), ) } @@ -135,7 +133,7 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv } WithPagination(schema) - return NewTool( + return NewToolWithSchemaOptions[ListCodeScanningAlertsInput, []*CodeScanningAlertOutput]( ToolsetMetadataCodeSecurity, mcp.Tool{ Name: "list_code_scanning_alerts", @@ -147,46 +145,27 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv InputSchema: schema, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - ref, err := OptionalParam[string](args, "ref") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - state, err := OptionalParam[string](args, "state") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - severity, err := OptionalParam[string](args, "severity") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - toolName, err := OptionalParam[string](args, "tool_name") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + inventory.TypedSchemaOptions{ + ValidationInputSchema: inventory.CloneSchemaWithoutDefaults(schema), + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ListCodeScanningAlertsInput) (*mcp.CallToolResult, []*CodeScanningAlertOutput, error) { + if args.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } + pagination := securityPagination(args.Page, args.PerPage) 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, args.Owner, args.Repo, &github.AlertListOptions{ + Ref: args.Ref, + State: args.State, + Severity: args.Severity, + ToolName: args.ToolName, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, @@ -219,7 +198,9 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv // visibility and embed attacker-influenceable code snippets, so the // label is always private-untrusted. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) - return result, nil, nil + return result, mapSecurityOutputs(alerts, codeScanningAlertOutput), nil }, + normalizeSecurityIntegerArguments("page", "perPage"), + normalizeTypedReadArguments(nil, false), ) } diff --git a/pkg/github/code_scanning_test.go b/pkg/github/code_scanning_test.go index c9978d8ac5..4095a46f1a 100644 --- a/pkg/github/code_scanning_test.go +++ b/pkg/github/code_scanning_test.go @@ -6,9 +6,8 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -17,7 +16,7 @@ import ( func Test_GetCodeScanningAlert(t *testing.T) { // Verify tool definition once toolDef := GetCodeScanningAlert(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + testSecurityToolSnapshot(t, toolDef.Tool) assert.Equal(t, "get_code_scanning_alert", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) @@ -32,10 +31,10 @@ func Test_GetCodeScanningAlert(t *testing.T) { // Setup mock alert for success case mockAlert := &github.Alert{ - Number: github.Ptr(42), - State: github.Ptr("open"), - Rule: &github.Rule{ID: github.Ptr("test-rule"), Description: github.Ptr("Test Rule Description")}, - HTMLURL: github.Ptr("https://github.com/owner/repo/security/code-scanning/42"), + Number: new(42), + State: new("open"), + Rule: &github.Rule{ID: new("test-rule"), Description: new("Test Rule Description")}, + HTMLURL: new("https://github.com/owner/repo/security/code-scanning/42"), } tests := []struct { @@ -123,7 +122,7 @@ func Test_GetCodeScanningAlert(t *testing.T) { func Test_ListCodeScanningAlerts(t *testing.T) { // Verify tool definition once toolDef := ListCodeScanningAlerts(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + testSecurityToolSnapshot(t, toolDef.Tool) assert.Equal(t, "list_code_scanning_alerts", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) @@ -144,16 +143,16 @@ func Test_ListCodeScanningAlerts(t *testing.T) { // Setup mock alerts for success case mockAlerts := []*github.Alert{ { - Number: github.Ptr(42), - State: github.Ptr("open"), - Rule: &github.Rule{ID: github.Ptr("test-rule-1"), Description: github.Ptr("Test Rule 1")}, - HTMLURL: github.Ptr("https://github.com/owner/repo/security/code-scanning/42"), + Number: new(42), + State: new("open"), + Rule: &github.Rule{ID: new("test-rule-1"), Description: new("Test Rule 1")}, + HTMLURL: new("https://github.com/owner/repo/security/code-scanning/42"), }, { - Number: github.Ptr(43), - State: github.Ptr("fixed"), - Rule: &github.Rule{ID: github.Ptr("test-rule-2"), Description: github.Ptr("Test Rule 2")}, - HTMLURL: github.Ptr("https://github.com/owner/repo/security/code-scanning/43"), + Number: new(43), + State: new("fixed"), + Rule: &github.Rule{ID: new("test-rule-2"), Description: new("Test Rule 2")}, + HTMLURL: new("https://github.com/owner/repo/security/code-scanning/43"), }, } diff --git a/pkg/github/comment_minimize.go b/pkg/github/comment_minimize.go new file mode 100644 index 0000000000..a80f7a79a9 --- /dev/null +++ b/pkg/github/comment_minimize.go @@ -0,0 +1,368 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "slices" + "strings" + + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +// MinimizeCommentResult is the response returned by the hide and unhide comment tools. +type MinimizeCommentResult struct { + NodeID string `json:"node_id"` + IsMinimized bool `json:"is_minimized"` + MinimizedReason string `json:"minimized_reason,omitempty" jsonschema:"GitHub's lowercase, hyphenated reason (for example off-topic); omitted when unhidden."` +} + +// CommentVisibilityInput identifies the comment or review whose visibility changes. +// The factory's target-specific schema determines which identifiers are required. +type CommentVisibilityInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + CommentID int64 `json:"comment_id,omitempty"` + PullNumber int `json:"pullNumber,omitempty"` + ReviewID int64 `json:"review_id,omitempty"` + Classifier string `json:"classifier,omitempty"` +} + +var commentClassifiers = []any{"SPAM", "ABUSE", "OFF_TOPIC", "OUTDATED", "DUPLICATE", "RESOLVED", "LOW_QUALITY"} + +const commentVisibilityPermissionNote = " Requires triage or write access to the repository, or being its author." + +// commentVisibilityTarget describes one kind of hideable object: how the tools that +// hide and unhide it are named and described, and how to find its GraphQL node ID. +type commentVisibilityTarget struct { + toolset inventory.ToolsetMetadata + featureRule inventory.FeatureRule + // name is appended to "hide_" and "unhide_" to form the tool names. + name string + title string + hideDescription string + unhideDescription string + properties func() map[string]*jsonschema.Schema + required []string + resolveNodeID func(ctx context.Context, client *github.Client, input CommentVisibilityInput) (string, *mcp.CallToolResult) +} + +var issueCommentVisibilityTarget = commentVisibilityTarget{ + toolset: ToolsetMetadataIssues, + featureRule: issuesGranularFeatureRule, + name: "issue_comment", + title: "Issue Comment", + hideDescription: "Hide (minimize) a comment on an issue, or a conversation comment on a pull request.", + unhideDescription: "Unhide (unminimize) a previously hidden comment on an issue, or conversation comment on a pull request.", + properties: func() map[string]*jsonschema.Schema { + return map[string]*jsonschema.Schema{ + "comment_id": { + Type: "number", + Description: "The numeric ID of the issue or pull request conversation comment", + Minimum: new(1.0), + }, + } + }, + required: []string{"comment_id"}, + resolveNodeID: func(ctx context.Context, client *github.Client, input CommentVisibilityInput) (string, *mcp.CallToolResult) { + comment, resp, err := client.Issues.GetComment(ctx, input.Owner, input.Repo, input.CommentID) + return nodeIDFromResponse(ctx, "failed to get issue comment", comment.GetNodeID(), resp, err) + }, +} + +var pullRequestReviewCommentVisibilityTarget = commentVisibilityTarget{ + toolset: ToolsetMetadataPullRequests, + featureRule: pullRequestsGranularFeatureRule, + name: "pull_request_review_comment", + title: "Pull Request Review Comment", + hideDescription: "Hide (minimize) an inline review comment on a pull request diff.", + unhideDescription: "Unhide (unminimize) a previously hidden inline review comment on a pull request diff.", + properties: func() map[string]*jsonschema.Schema { + return map[string]*jsonschema.Schema{ + "comment_id": { + Type: "number", + Description: "The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", + Minimum: new(1.0), + }, + } + }, + required: []string{"comment_id"}, + resolveNodeID: func(ctx context.Context, client *github.Client, input CommentVisibilityInput) (string, *mcp.CallToolResult) { + comment, resp, err := client.PullRequests.GetComment(ctx, input.Owner, input.Repo, input.CommentID) + return nodeIDFromResponse(ctx, "failed to get pull request review comment", comment.GetNodeID(), resp, err) + }, +} + +var pullRequestReviewVisibilityTarget = commentVisibilityTarget{ + toolset: ToolsetMetadataPullRequests, + featureRule: pullRequestsGranularFeatureRule, + name: "pull_request_review", + title: "Pull Request Review", + hideDescription: "Hide (minimize) the body of a submitted pull request review.", + unhideDescription: "Unhide (unminimize) the previously hidden body of a submitted pull request review.", + properties: func() map[string]*jsonschema.Schema { + return map[string]*jsonschema.Schema{ + "pullNumber": { + Type: "number", + Description: "The pull request number", + Minimum: new(1.0), + }, + "review_id": { + Type: "number", + Description: "The numeric ID of the pull request review", + Minimum: new(1.0), + }, + } + }, + required: []string{"pullNumber", "review_id"}, + resolveNodeID: func(ctx context.Context, client *github.Client, input CommentVisibilityInput) (string, *mcp.CallToolResult) { + review, resp, err := client.PullRequests.GetReview(ctx, input.Owner, input.Repo, input.PullNumber, input.ReviewID) + return nodeIDFromResponse(ctx, "failed to get pull request review", review.GetNodeID(), resp, err) + }, +} + +// commentVisibilityTool builds the hide_ tool when hide is true, and the unhide_ tool otherwise. +func commentVisibilityTool(t translations.TranslationHelperFunc, target commentVisibilityTarget, hide bool) inventory.ServerTool { + action, titleAction, description := "unhide", "Unhide", target.unhideDescription + if hide { + action, titleAction, description = "hide", "Hide", target.hideDescription + } + name := action + "_" + target.name + + properties := map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + } + maps.Copy(properties, target.properties()) + required := append([]string{"owner", "repo"}, target.required...) + if hide { + properties["classifier"] = &jsonschema.Schema{ + Type: "string", + Description: "The reason for hiding the comment", + Enum: commentClassifiers, + } + required = append(required, "classifier") + } + + outputSchema, err := inventory.CachedSchemaFor[MinimizeCommentResult](nil) + if err != nil { + panic(fmt.Sprintf("failed to generate comment visibility output schema: %v", err)) + } + st := NewTool[CommentVisibilityInput, MinimizeCommentResult]( + target.toolset, + mcp.Tool{ + Name: name, + Description: t("TOOL_"+strings.ToUpper(name)+"_DESCRIPTION", description+commentVisibilityPermissionNote), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_"+strings.ToUpper(name)+"_USER_TITLE", titleAction+" "+target.title), + ReadOnlyHint: false, + DestructiveHint: new(false), + OpenWorldHint: new(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: properties, + Required: required, + }, + OutputSchema: outputSchema, + }, + scopes.RequireAll(scopes.Repo), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input CommentVisibilityInput) (*mcp.CallToolResult, MinimizeCommentResult, error) { + result, output := setCommentVisibility(ctx, deps, target, input, hide) + return result, output, nil + }, + normalizeCommentVisibilityArguments(target, hide), + ) + st.FeatureRule = target.featureRule + return st +} + +func normalizeCommentVisibilityArguments(target commentVisibilityTarget, hide bool) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + if args == nil { + return raw, nil + } + input, err := normalizeCommentVisibilityInput(args, target, hide) + if err != nil { + return nil, err + } + normalized, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("marshal normalized comment visibility arguments: %w", err) + } + return normalized, nil + } +} + +func normalizeCommentVisibilityInput(args map[string]any, target commentVisibilityTarget, hide bool) (CommentVisibilityInput, error) { + var input CommentVisibilityInput + var err error + input.Owner, err = RequiredParam[string](args, "owner") + if err != nil { + return input, err + } + input.Repo, err = RequiredParam[string](args, "repo") + if err != nil { + return input, err + } + if hide { + input.Classifier, err = RequiredParam[string](args, "classifier") + if err != nil { + return input, err + } + input.Classifier = strings.ToUpper(input.Classifier) + if !slices.Contains(commentClassifiers, any(input.Classifier)) { + return input, fmt.Errorf("invalid classifier %q: must be one of %v", input.Classifier, commentClassifiers) + } + } + for _, name := range target.required { + if name == "pullNumber" { + value, err := RequiredInt(args, name) + if err != nil { + return input, err + } + if value < 1 { + return input, fmt.Errorf("pullNumber must be greater than 0") + } + input.PullNumber = value + continue + } + value, err := requiredPositiveBigInt(args, name) + if err != nil { + return input, err + } + if name == "comment_id" { + input.CommentID = value + } else { + input.ReviewID = value + } + } + return input, nil +} + +// setCommentVisibility resolves the object identified by input and hides or unhides it. +func setCommentVisibility(ctx context.Context, deps ToolDependencies, target commentVisibilityTarget, input CommentVisibilityInput, hide bool) (*mcp.CallToolResult, MinimizeCommentResult) { + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), MinimizeCommentResult{} + } + + nodeID, errResult := target.resolveNodeID(ctx, client, input) + if errResult != nil { + return errResult, MinimizeCommentResult{} + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), MinimizeCommentResult{} + } + + var result MinimizeCommentResult + if hide { + result, errResult = minimizeComment(ctx, gqlClient, nodeID, input.Classifier) + } else { + result, errResult = unminimizeComment(ctx, gqlClient, nodeID) + } + if errResult != nil { + return errResult, MinimizeCommentResult{} + } + + r, err := json.Marshal(result) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), MinimizeCommentResult{} + } + return utils.NewToolResultText(string(r)), result +} + +// requiredPositiveBigInt validates legacy numeric IDs before SDK schema validation. +func requiredPositiveBigInt(args map[string]any, p string) (int64, error) { + v, err := RequiredBigInt(args, p) + if err != nil { + return 0, err + } + if v < 1 { + return 0, fmt.Errorf("%s must be greater than 0", p) + } + return v, nil +} + +// nodeIDFromResponse turns the result of a REST lookup into a GraphQL node ID, since the +// minimize mutations only accept node IDs. +func nodeIDFromResponse(ctx context.Context, errMessage, nodeID string, resp *github.Response, err error) (string, *mcp.CallToolResult) { + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return "", ghErrors.NewGitHubAPIErrorResponse(ctx, errMessage, resp, err) + } + if nodeID == "" { + return "", utils.NewToolResultError(errMessage + ": response has no node ID") + } + return nodeID, nil +} + +func minimizeComment(ctx context.Context, client *githubv4.Client, nodeID, classifier string) (MinimizeCommentResult, *mcp.CallToolResult) { + var mutation struct { + MinimizeComment struct { + MinimizedComment struct { + IsMinimized githubv4.Boolean + MinimizedReason githubv4.String + } + } `graphql:"minimizeComment(input: $input)"` + } + + input := githubv4.MinimizeCommentInput{ + SubjectID: githubv4.ID(nodeID), + Classifier: githubv4.ReportedContentClassifiers(classifier), + } + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return MinimizeCommentResult{}, ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to minimize comment", err) + } + + return MinimizeCommentResult{ + NodeID: nodeID, + IsMinimized: bool(mutation.MinimizeComment.MinimizedComment.IsMinimized), + MinimizedReason: string(mutation.MinimizeComment.MinimizedComment.MinimizedReason), + }, nil +} + +func unminimizeComment(ctx context.Context, client *githubv4.Client, nodeID string) (MinimizeCommentResult, *mcp.CallToolResult) { + var mutation struct { + UnminimizeComment struct { + UnminimizedComment struct { + IsMinimized githubv4.Boolean + } + } `graphql:"unminimizeComment(input: $input)"` + } + + input := githubv4.UnminimizeCommentInput{ + SubjectID: githubv4.ID(nodeID), + } + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return MinimizeCommentResult{}, ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to unminimize comment", err) + } + + return MinimizeCommentResult{ + NodeID: nodeID, + IsMinimized: bool(mutation.UnminimizeComment.UnminimizedComment.IsMinimized), + }, nil +} diff --git a/pkg/github/comment_minimize_test.go b/pkg/github/comment_minimize_test.go new file mode 100644 index 0000000000..9411500bd8 --- /dev/null +++ b/pkg/github/comment_minimize_test.go @@ -0,0 +1,707 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "maps" + "net/http" + "testing" + + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + getIssueCommentRoute = "GET /repos/{owner}/{repo}/issues/comments/{comment_id}" + getReviewCommentRoute = "GET /repos/{owner}/{repo}/pulls/comments/{comment_id}" + getReviewRoute = "GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}" +) + +// minimizedReason is the lowercase, hyphenated form GitHub returns (e.g. "off-topic"), not the classifier enum. +func minimizeCommentMatcher(nodeID, classifier, minimizedReason string) githubv4mock.Matcher { + return githubv4mock.NewMutationMatcher( + struct { + MinimizeComment struct { + MinimizedComment struct { + IsMinimized githubv4.Boolean + MinimizedReason githubv4.String + } + } `graphql:"minimizeComment(input: $input)"` + }{}, + githubv4.MinimizeCommentInput{ + SubjectID: githubv4.ID(nodeID), + Classifier: githubv4.ReportedContentClassifiers(classifier), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "minimizeComment": map[string]any{ + "minimizedComment": map[string]any{ + "isMinimized": true, + "minimizedReason": minimizedReason, + }, + }, + }), + ) +} + +func unminimizeCommentMatcher(nodeID string) githubv4mock.Matcher { + return githubv4mock.NewMutationMatcher( + struct { + UnminimizeComment struct { + UnminimizedComment struct { + IsMinimized githubv4.Boolean + } + } `graphql:"unminimizeComment(input: $input)"` + }{}, + githubv4.UnminimizeCommentInput{SubjectID: githubv4.ID(nodeID)}, + nil, + githubv4mock.DataResponse(map[string]any{ + "unminimizeComment": map[string]any{ + "unminimizedComment": map[string]any{"isMinimized": false}, + }, + }), + ) +} + +func minimizeCommentErrorMatcher(nodeID, classifier string) githubv4mock.Matcher { + return githubv4mock.NewMutationMatcher( + struct { + MinimizeComment struct { + MinimizedComment struct { + IsMinimized githubv4.Boolean + MinimizedReason githubv4.String + } + } `graphql:"minimizeComment(input: $input)"` + }{}, + githubv4.MinimizeCommentInput{ + SubjectID: githubv4.ID(nodeID), + Classifier: githubv4.ReportedContentClassifiers(classifier), + }, + nil, + githubv4mock.ErrorResponse("Resource not accessible by integration"), + ) +} + +func unminimizeCommentErrorMatcher(nodeID string) githubv4mock.Matcher { + return githubv4mock.NewMutationMatcher( + struct { + UnminimizeComment struct { + UnminimizedComment struct { + IsMinimized githubv4.Boolean + } + } `graphql:"unminimizeComment(input: $input)"` + }{}, + githubv4.UnminimizeCommentInput{SubjectID: githubv4.ID(nodeID)}, + nil, + githubv4mock.ErrorResponse("Resource not accessible by integration"), + ) +} + +func Test_CommentVisibilityToolSchemas(t *testing.T) { + tests := []struct { + tool inventory.ServerTool + name string + toolset inventory.ToolsetID + featureFlag string + expectedRequire []string + }{ + { + tool: GranularHideIssueComment(translations.NullTranslationHelper), + name: "hide_issue_comment", + toolset: ToolsetMetadataIssues.ID, + featureFlag: FeatureFlagIssuesGranular, + expectedRequire: []string{"owner", "repo", "comment_id", "classifier"}, + }, + { + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + name: "unhide_issue_comment", + toolset: ToolsetMetadataIssues.ID, + featureFlag: FeatureFlagIssuesGranular, + expectedRequire: []string{"owner", "repo", "comment_id"}, + }, + { + tool: GranularHidePullRequestReviewComment(translations.NullTranslationHelper), + name: "hide_pull_request_review_comment", + toolset: ToolsetMetadataPullRequests.ID, + featureFlag: FeatureFlagPullRequestsGranular, + expectedRequire: []string{"owner", "repo", "comment_id", "classifier"}, + }, + { + tool: GranularUnhidePullRequestReviewComment(translations.NullTranslationHelper), + name: "unhide_pull_request_review_comment", + toolset: ToolsetMetadataPullRequests.ID, + featureFlag: FeatureFlagPullRequestsGranular, + expectedRequire: []string{"owner", "repo", "comment_id"}, + }, + { + tool: GranularHidePullRequestReview(translations.NullTranslationHelper), + name: "hide_pull_request_review", + toolset: ToolsetMetadataPullRequests.ID, + featureFlag: FeatureFlagPullRequestsGranular, + expectedRequire: []string{"owner", "repo", "pullNumber", "review_id", "classifier"}, + }, + { + tool: GranularUnhidePullRequestReview(translations.NullTranslationHelper), + name: "unhide_pull_request_review", + toolset: ToolsetMetadataPullRequests.ID, + featureFlag: FeatureFlagPullRequestsGranular, + expectedRequire: []string{"owner", "repo", "pullNumber", "review_id"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tool := tc.tool.Tool + if tc.toolset != ToolsetMetadataIssues.ID { + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + } + + assert.Equal(t, tc.name, tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint) + assert.Equal(t, tc.toolset, tc.tool.Toolset.ID) + assert.Equal(t, []inventory.FeatureFlag{inventory.FeatureFlag(tc.featureFlag)}, tc.tool.FeatureRule.Features()) + assert.Equal(t, []string{"repo"}, tc.tool.ScopeAccess.Scopes) + assert.True(t, tc.tool.ScopeAccess.Visible([]string{"repo"})) + assert.False(t, tc.tool.ScopeAccess.Visible(nil)) + assert.Equal(t, []string{"repo"}, tc.tool.ScopeAccess.Challenge(nil, nil)) + assert.Empty(t, tc.tool.ScopeAccess.Challenge(nil, []string{"repo"})) + + schema := tool.InputSchema.(*jsonschema.Schema) + assert.ElementsMatch(t, tc.expectedRequire, schema.Required) + assert.Len(t, schema.Properties, len(tc.expectedRequire), "every property should be required") + }) + } +} + +func TestCommentVisibilityTypedInputSchemaMatchesLegacy(t *testing.T) { + for _, target := range []commentVisibilityTarget{issueCommentVisibilityTarget, pullRequestReviewCommentVisibilityTarget, pullRequestReviewVisibilityTarget} { + for _, hide := range []bool{true, false} { + tool := commentVisibilityTool(translations.NullTranslationHelper, target, hide) + t.Run(tool.Tool.Name, func(t *testing.T) { + legacyProperties := map[string]*jsonschema.Schema{ + "owner": {Type: "string", Description: "Repository owner (username or organization)"}, + "repo": {Type: "string", Description: "Repository name"}, + } + maps.Copy(legacyProperties, target.properties()) + legacyRequired := append([]string{"owner", "repo"}, target.required...) + if hide { + legacyProperties["classifier"] = &jsonschema.Schema{ + Type: "string", + Description: "The reason for hiding the comment", + Enum: commentClassifiers, + } + legacyRequired = append(legacyRequired, "classifier") + } + legacy := &jsonschema.Schema{ + Type: "object", + Properties: legacyProperties, + Required: legacyRequired, + } + got, err := json.Marshal(tool.Tool.InputSchema) + require.NoError(t, err) + want, err := json.Marshal(legacy) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) + }) + } + } +} + +func connectCommentVisibilityClient(t *testing.T, server *mcp.Server, version string) *mcp.ClientSession { + t.Helper() + if version == "2025-11-25" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if method == "server/discover" { + return nil, errors.New("use initialize for legacy protocol compatibility test") + } + return next(ctx, method, req) + } + }) + } + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "comment-visibility-test", Version: "v1"}, nil) + session, err := client.Connect(context.Background(), commentVisibilityProtocolTransport{clientTransport, version}, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + require.Equal(t, version, session.InitializeResult().ProtocolVersion) + return session +} + +type commentVisibilityProtocolTransport struct { + mcp.Transport + version string +} + +func (t commentVisibilityProtocolTransport) Connect(ctx context.Context) (mcp.Connection, error) { + connection, err := t.Transport.Connect(ctx) + if err != nil { + return nil, err + } + return commentVisibilityProtocolConnection{connection, t.version}, nil +} + +type commentVisibilityProtocolConnection struct { + mcp.Connection + version string +} + +func (c commentVisibilityProtocolConnection) Write(ctx context.Context, message jsonrpc.Message) error { + req, ok := message.(*jsonrpc.Request) + if !ok || req.Method != "initialize" { + return c.Connection.Write(ctx, message) + } + var params map[string]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return err + } + version, err := json.Marshal(c.version) + if err != nil { + return err + } + params["protocolVersion"] = version + encoded, err := json.Marshal(params) + if err != nil { + return err + } + requestCopy := *req + requestCopy.Params = encoded + return c.Connection.Write(ctx, &requestCopy) +} + +func TestCommentVisibilityProtocols(t *testing.T) { + // Typed output requires a supported negotiated 2026-07-28+ version; + // unknown versions remain legacy even when their date is later. + for _, version := range []string{"2025-11-25", "2026-07-28"} { + for _, target := range []commentVisibilityTarget{issueCommentVisibilityTarget, pullRequestReviewCommentVisibilityTarget, pullRequestReviewVisibilityTarget} { + for _, hide := range []bool{true, false} { + tool := commentVisibilityTool(translations.NullTranslationHelper, target, hide) + t.Run(version+"/"+tool.Tool.Name, func(t *testing.T) { + route := getIssueCommentRoute + switch target.name { + case "pull_request_review_comment": + route = getReviewCommentRoute + case "pull_request_review": + route = getReviewRoute + } + matcher := unminimizeCommentMatcher("NODE_1") + expected := MinimizeCommentResult{NodeID: "NODE_1"} + legacyText := `{"node_id":"NODE_1","is_minimized":false}` + if hide { + matcher = minimizeCommentMatcher("NODE_1", "OFF_TOPIC", "off-topic") + expected.IsMinimized, expected.MinimizedReason = true, "off-topic" + legacyText = `{"node_id":"NODE_1","is_minimized":true,"minimized_reason":"off-topic"}` + } + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + route: mockResponse(t, http.StatusOK, map[string]any{"node_id": "NODE_1"}), + })), + GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)), + } + inv, err := inventory.NewBuilder().SetTools([]inventory.ServerTool{tool}). + WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return true, nil }).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "visibility-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + if version == "2025-11-25" { + assert.Nil(t, list.Tools[0].OutputSchema) + } else { + assert.NotNil(t, list.Tools[0].OutputSchema) + } + args := map[string]any{"owner": "owner", "repo": "repo", "extra": "ignored"} + if target.name == "pull_request_review" { + args["pullNumber"], args["review_id"] = "42", "3" + } else { + args["comment_id"] = "1e0" + } + if hide { + args["classifier"] = "oFf_ToPiC" + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, result) + require.Len(t, result.Content, 1) + textResult := getTextResult(t, result).Text + if version == "2025-11-25" { + assert.Equal(t, legacyText, textResult) + assert.Nil(t, result.StructuredContent) + } else { + var textOutput MinimizeCommentResult + require.NoError(t, json.Unmarshal([]byte(textResult), &textOutput)) + assert.Equal(t, expected, textOutput) + text, err := json.Marshal(textOutput) + require.NoError(t, err) + structured, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, string(text), string(structured)) + schema := tool.Tool.OutputSchema.(*jsonschema.Schema) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal(structured, &value)) + require.NoError(t, resolved.Validate(value)) + } + invalid := maps.Clone(args) + delete(invalid, "owner") + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name, Arguments: invalid}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + assert.Contains(t, getErrorResult(t, result).Text, "missing required parameter: owner") + }) + } + } + } +} + +func TestCommentVisibilityProtocolGating(t *testing.T) { + for _, version := range []string{"2025-11-25", "2026-07-28"} { + for _, target := range []commentVisibilityTarget{issueCommentVisibilityTarget, pullRequestReviewCommentVisibilityTarget, pullRequestReviewVisibilityTarget} { + for _, hide := range []bool{true, false} { + tool := commentVisibilityTool(translations.NullTranslationHelper, target, hide) + for _, gate := range []string{"feature disabled", "missing repo scope", "read only"} { + t.Run(version+"/"+tool.Tool.Name+"/"+gate, func(t *testing.T) { + builder := inventory.NewBuilder().SetTools([]inventory.ServerTool{tool}). + WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return gate != "feature disabled", nil }) + if gate == "missing repo scope" { + builder.WithFilter(CreateToolScopeFilter([]string{"public_repo"})) + } + if gate == "read only" { + builder.WithReadOnly(true) + } + inv, err := builder.Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "visibility-gates", Version: "v1"}, nil) + inv.RegisterTools(context.Background(), server, nil) + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + assert.Empty(t, list.Tools) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name}) + if err == nil { + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + } + }) + } + } + } + } +} + +func TestNormalizeCommentVisibilityInput(t *testing.T) { + for _, target := range []commentVisibilityTarget{issueCommentVisibilityTarget, pullRequestReviewCommentVisibilityTarget, pullRequestReviewVisibilityTarget} { + for _, tc := range []struct { + name string + value any + want int64 + }{ + {"number", float64(1), 1}, + {"string", "1", 1}, + {"decimal string", "1.0", 1}, + {"exponent string", "1e0", 1}, + {"signed string", "+1", 1}, + {"leading zero string", "001", 1}, + {"legacy float precision", "9007199254740993", 9007199254740992}, + {"largest round-trippable positive ID", "9223372036854774784", 9223372036854774784}, + } { + t.Run(target.name+"/"+tc.name, func(t *testing.T) { + args := map[string]any{"owner": "owner", "repo": "repo", "classifier": "sPaM"} + for _, name := range target.required { + args[name] = tc.value + } + if target.name == "pull_request_review" { + args["pullNumber"] = "42" + } + input, err := normalizeCommentVisibilityInput(args, target, true) + require.NoError(t, err) + tool := commentVisibilityTool(translations.NullTranslationHelper, target, true) + schema, err := tool.Tool.InputSchema.(*jsonschema.Schema).Resolve(nil) + require.NoError(t, err) + encoded, err := json.Marshal(input) + require.NoError(t, err) + var canonical any + require.NoError(t, json.Unmarshal(encoded, &canonical)) + require.NoError(t, schema.Validate(canonical)) + assert.Equal(t, "SPAM", input.Classifier) + if target.name == "pull_request_review" { + assert.Equal(t, tc.want, input.ReviewID) + assert.Equal(t, 42, input.PullNumber) + } else { + assert.Equal(t, tc.want, input.CommentID) + } + }) + } + } +} + +func Test_HideAndUnhideComments(t *testing.T) { + issueComment := mockResponse(t, http.StatusOK, &github.IssueComment{ID: new(int64(1)), NodeID: new("IC_1")}) + reviewComment := mockResponse(t, http.StatusOK, &github.PullRequestComment{ID: new(int64(2)), NodeID: new("PRRC_2")}) + review := mockResponse(t, http.StatusOK, &github.PullRequestReview{ID: new(int64(3)), NodeID: new("PRR_3")}) + notFound := mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`) + + type visibilityTestCase struct { + name string + tool inventory.ServerTool + restHandlers map[string]http.HandlerFunc + gqlMatchers []githubv4mock.Matcher + requestArgs map[string]any + expectedResult MinimizeCommentResult + expectedErrMsg string + } + tests := []visibilityTestCase{ + { + name: "hide issue comment", + tool: GranularHideIssueComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getIssueCommentRoute: issueComment}, + gqlMatchers: []githubv4mock.Matcher{minimizeCommentMatcher("IC_1", "SPAM", "spam")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(1), "classifier": "spam"}, + expectedResult: MinimizeCommentResult{NodeID: "IC_1", IsMinimized: true, MinimizedReason: "spam"}, + }, + { + name: "unhide issue comment", + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getIssueCommentRoute: issueComment}, + gqlMatchers: []githubv4mock.Matcher{unminimizeCommentMatcher("IC_1")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(1)}, + expectedResult: MinimizeCommentResult{NodeID: "IC_1", IsMinimized: false}, + }, + { + name: "hide pull request review comment", + tool: GranularHidePullRequestReviewComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getReviewCommentRoute: reviewComment}, + gqlMatchers: []githubv4mock.Matcher{minimizeCommentMatcher("PRRC_2", "OUTDATED", "outdated")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(2), "classifier": "OUTDATED"}, + expectedResult: MinimizeCommentResult{NodeID: "PRRC_2", IsMinimized: true, MinimizedReason: "outdated"}, + }, + { + name: "unhide pull request review comment", + tool: GranularUnhidePullRequestReviewComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getReviewCommentRoute: reviewComment}, + gqlMatchers: []githubv4mock.Matcher{unminimizeCommentMatcher("PRRC_2")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(2)}, + expectedResult: MinimizeCommentResult{NodeID: "PRRC_2", IsMinimized: false}, + }, + { + name: "hide pull request review", + tool: GranularHidePullRequestReview(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getReviewRoute: review}, + gqlMatchers: []githubv4mock.Matcher{minimizeCommentMatcher("PRR_3", "RESOLVED", "resolved")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "pullNumber": float64(42), "review_id": float64(3), "classifier": "RESOLVED"}, + expectedResult: MinimizeCommentResult{NodeID: "PRR_3", IsMinimized: true, MinimizedReason: "resolved"}, + }, + { + name: "unhide pull request review", + tool: GranularUnhidePullRequestReview(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getReviewRoute: review}, + gqlMatchers: []githubv4mock.Matcher{unminimizeCommentMatcher("PRR_3")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "pullNumber": float64(42), "review_id": float64(3)}, + expectedResult: MinimizeCommentResult{NodeID: "PRR_3", IsMinimized: false}, + }, + { + name: "issue comment not found", + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getIssueCommentRoute: notFound}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(1)}, + expectedErrMsg: "failed to get issue comment", + }, + { + name: "pull request review comment not found", + tool: GranularUnhidePullRequestReviewComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getReviewCommentRoute: notFound}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(2)}, + expectedErrMsg: "failed to get pull request review comment", + }, + { + name: "pull request review not found", + tool: GranularUnhidePullRequestReview(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getReviewRoute: notFound}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "pullNumber": float64(42), "review_id": float64(3)}, + expectedErrMsg: "failed to get pull request review", + }, + { + name: "response without node ID", + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{ + getIssueCommentRoute: mockResponse(t, http.StatusOK, &github.IssueComment{ID: new(int64(1))}), + }, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(1)}, + expectedErrMsg: "response has no node ID", + }, + { + name: "hide mutation fails", + tool: GranularHideIssueComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getIssueCommentRoute: issueComment}, + gqlMatchers: []githubv4mock.Matcher{minimizeCommentErrorMatcher("IC_1", "SPAM")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(1), "classifier": "SPAM"}, + expectedErrMsg: "failed to minimize comment", + }, + { + name: "unhide mutation fails", + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getIssueCommentRoute: issueComment}, + gqlMatchers: []githubv4mock.Matcher{unminimizeCommentErrorMatcher("IC_1")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(1)}, + expectedErrMsg: "failed to unminimize comment", + }, + { + name: "invalid classifier is rejected before any API call", + tool: GranularHideIssueComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(1), "classifier": "BOGUS"}, + expectedErrMsg: `invalid classifier "BOGUS"`, + }, + { + name: "negative issue comment_id", + tool: GranularHideIssueComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(-1), "classifier": "SPAM"}, + expectedErrMsg: "comment_id must be greater than 0", + }, + { + name: "negative pull request review comment_id", + tool: GranularUnhidePullRequestReviewComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(-2)}, + expectedErrMsg: "comment_id must be greater than 0", + }, + { + name: "negative review_id", + tool: GranularUnhidePullRequestReview(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "pullNumber": float64(42), "review_id": float64(-3)}, + expectedErrMsg: "review_id must be greater than 0", + }, + { + name: "negative pullNumber", + tool: GranularUnhidePullRequestReview(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "pullNumber": float64(-42), "review_id": float64(3)}, + expectedErrMsg: "pullNumber must be greater than 0", + }, + { + name: "missing owner", + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"repo": "repo", "comment_id": float64(1)}, + expectedErrMsg: "owner", + }, + { + name: "missing repo", + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "comment_id": float64(1)}, + expectedErrMsg: "repo", + }, + { + name: "missing classifier", + tool: GranularHideIssueComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(1)}, + expectedErrMsg: "classifier", + }, + { + name: "missing issue comment_id", + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo"}, + expectedErrMsg: "comment_id", + }, + { + name: "missing pull request review comment_id", + tool: GranularUnhidePullRequestReviewComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo"}, + expectedErrMsg: "comment_id", + }, + { + name: "missing pullNumber", + tool: GranularUnhidePullRequestReview(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "review_id": float64(3)}, + expectedErrMsg: "pullNumber", + }, + { + name: "missing review_id", + tool: GranularUnhidePullRequestReview(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "pullNumber": float64(42)}, + expectedErrMsg: "review_id", + }, + { + name: "unhide ignores classifier and unrelated identifiers", + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getIssueCommentRoute: issueComment}, + gqlMatchers: []githubv4mock.Matcher{unminimizeCommentMatcher("IC_1")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": "1.0", "classifier": false, "review_id": "invalid", "pullNumber": nil}, + expectedResult: MinimizeCommentResult{NodeID: "IC_1"}, + }, + } + for _, tc := range []struct { + name string + value any + error string + }{ + {"zero", float64(0), "missing required parameter: comment_id"}, + {"string zero", "0", "missing required parameter: comment_id"}, + {"fractional", 1.5, "non-integer numeric value"}, + {"fractional string", "1.5", "non-integer numeric value"}, + {"invalid string", "abc", "invalid numeric value"}, + {"null", nil, "expected number, got "}, + {"boolean", true, "expected number, got bool"}, + {"string NaN", "NaN", "non-finite numeric value"}, + {"string infinity", "+Inf", "non-finite numeric value"}, + {"overflow", "9223372036854777856", "too large to fit in int64"}, + } { + tests = append(tests, visibilityTestCase{ + name: "issue ID " + tc.name, + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": tc.value}, + expectedErrMsg: tc.error, + }) + } + + for _, tc := range tests { + for _, version := range []string{"2025-11-25", "2026-07-28"} { + t.Run(version+"/"+tc.name, func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(tc.restHandlers)), + GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient(tc.gqlMatchers...)), + } + inv, err := inventory.NewBuilder().SetTools([]inventory.ServerTool{tc.tool}). + WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return true, nil }).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "visibility-behavior", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + session := connectCommentVisibilityClient(t, server, version) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.requestArgs}) + require.NoError(t, err) + + if tc.expectedErrMsg != "" { + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.expectedErrMsg) + assert.Nil(t, result.StructuredContent) + return + } + + require.False(t, result.IsError, getTextResult(t, result).Text) + var got MinimizeCommentResult + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &got)) + assert.Equal(t, tc.expectedResult, got) + }) + } + } +} diff --git a/pkg/github/consolidated_issue_types.go b/pkg/github/consolidated_issue_types.go new file mode 100644 index 0000000000..ccd84e0173 --- /dev/null +++ b/pkg/github/consolidated_issue_types.go @@ -0,0 +1,620 @@ +package github + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type IssueWriteInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + IssueNumber *int `json:"issue_number,omitempty"` + ParentIssueNumber *int `json:"parent_issue_number,omitempty"` + ParentOwner *string `json:"parent_owner,omitempty"` + ParentRepo *string `json:"parent_repo,omitempty"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + Assignees *[]string `json:"assignees,omitempty"` + Labels *[]string `json:"labels,omitempty"` + Milestone *int `json:"milestone,omitempty"` + Type *string `json:"type,omitempty"` + State *string `json:"state,omitempty"` + StateReason *string `json:"state_reason,omitempty"` + DuplicateOf *int `json:"duplicate_of,omitempty"` + IssueFields []IssueWriteFieldInput `json:"issue_fields,omitempty"` + TypeProvided bool `json:"-"` +} + +func (input *IssueWriteInput) UnmarshalJSON(raw []byte) error { + type plain IssueWriteInput + var decoded plain + if err := json.Unmarshal(raw, &decoded); err != nil { + return err + } + var presence struct { + Type json.RawMessage `json:"type"` + } + if err := json.Unmarshal(raw, &presence); err != nil { + return err + } + *input = IssueWriteInput(decoded) + input.TypeProvided = len(presence.Type) != 0 + return nil +} + +type IssueWriteFieldInput struct { + FieldName string `json:"field_name"` + Value *IssueFieldValue `json:"value,omitempty"` + FieldOptionName *string `json:"field_option_name,omitempty"` + Delete *bool `json:"delete,omitempty"` +} + +// IssueFieldValue represents the REST scalar without losing its JSON type. +// Boolean inputs were historically accepted and forwarded to the API as well. +type IssueFieldValue struct { + String *string + Number *float64 + Bool *bool +} + +func (value IssueFieldValue) MarshalJSON() ([]byte, error) { + switch { + case value.String != nil: + return json.Marshal(value.String) + case value.Number != nil: + return json.Marshal(value.Number) + case value.Bool != nil: + return json.Marshal(value.Bool) + default: + return []byte("null"), nil + } +} + +func (value *IssueFieldValue) UnmarshalJSON(raw []byte) error { + *value = IssueFieldValue{} + switch { + case len(raw) > 0 && raw[0] == '"': + return json.Unmarshal(raw, &value.String) + case string(raw) == "true" || string(raw) == "false": + return json.Unmarshal(raw, &value.Bool) + case string(raw) == "null": + return nil + default: + return json.Unmarshal(raw, &value.Number) + } +} + +func issueFieldValue(value any) (*IssueFieldValue, error) { + switch value := value.(type) { + case nil: + return nil, nil + case string: + return &IssueFieldValue{String: &value}, nil + case float64: + return &IssueFieldValue{Number: &value}, nil + case bool: + return &IssueFieldValue{Bool: &value}, nil + default: + return nil, fmt.Errorf("unsupported issue field value type %T", value) + } +} + +type IssueReadOutput struct { + Method string `json:"method"` + Issue *IssueDetailsOutput `json:"issue,omitempty"` + Comments *[]IssueReadCommentOutput `json:"comments,omitempty"` + SubIssues *[]*SubIssueOutput `json:"sub_issues,omitempty"` + Parent *IssueParentRef `json:"parent,omitempty"` + Labels *[]IssueLabelOutput `json:"labels,omitempty"` + TotalCount *int `json:"totalCount,omitempty"` +} + +func (out IssueReadOutput) MarshalJSON() ([]byte, error) { + if out.Method == "" { + // SDK v1.8 validates this zero value even for errors before the + // protocol middleware removes it from the wire result. + return []byte(`{"method":"get","issue":null}`), nil + } + if out.Method == "get_parent" { + return json.Marshal(struct { + Method string `json:"method"` + Parent *IssueParentRef `json:"parent"` + }{out.Method, out.Parent}) + } + type plain IssueReadOutput + return json.Marshal(plain(out)) +} + +// IssueDetailsOutput excludes REST issue_field_values: get always drops them +// and replaces them with the GraphQL field_values representation. +type IssueDetailsOutput struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + State string `json:"state" jsonschema:"Issue state (lowercase REST value)."` + StateReason string `json:"state_reason,omitempty"` + Draft bool `json:"draft,omitempty"` + Locked bool `json:"locked,omitempty"` + HTMLURL string `json:"html_url,omitempty" jsonschema:"Human-readable issue link."` + User *IssueUserOutput `json:"user,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees"` + Milestone string `json:"milestone,omitempty"` + Comments int `json:"comments,omitempty" jsonschema:"Number of comments."` + Reactions *MinimalReactions `json:"reactions,omitempty"` + CreatedAt string `json:"created_at,omitempty" jsonschema:"Creation time (RFC 3339)."` + UpdatedAt string `json:"updated_at,omitempty" jsonschema:"Last update time (RFC 3339)."` + ClosedAt string `json:"closed_at,omitempty" jsonschema:"Closure time (RFC 3339), when closed."` + ClosedBy string `json:"closed_by,omitempty"` + IssueType string `json:"issue_type,omitempty"` + FieldValues []MinimalFieldValue `json:"field_values,omitempty"` + HasParent *bool `json:"has_parent,omitempty"` + HasChildren *bool `json:"has_children,omitempty"` + Parent *MinimalIssueRef `json:"parent,omitempty"` + SubIssuesSummary *MinimalSubIssuesSummary `json:"sub_issues_summary,omitempty"` + ClosedByPullRequests *MinimalClosingPullRequests `json:"closed_by_pull_requests,omitempty"` +} + +func issueDetailsOutput(issue MinimalIssue) *IssueDetailsOutput { + return &IssueDetailsOutput{ + Number: issue.Number, Title: issue.Title, Body: issue.Body, State: issue.State, + StateReason: issue.StateReason, Draft: issue.Draft, Locked: issue.Locked, + HTMLURL: issue.HTMLURL, User: issueUserOutput(issue.User), AuthorAssociation: issue.AuthorAssociation, + Labels: issue.Labels, Assignees: issue.Assignees, Milestone: issue.Milestone, + Comments: issue.Comments, Reactions: issue.Reactions, CreatedAt: issue.CreatedAt, + UpdatedAt: issue.UpdatedAt, ClosedAt: issue.ClosedAt, ClosedBy: issue.ClosedBy, + IssueType: issue.IssueType, FieldValues: issue.FieldValues, HasParent: issue.HasParent, + HasChildren: issue.HasChildren, Parent: issue.Parent, SubIssuesSummary: issue.SubIssuesSummary, + ClosedByPullRequests: issue.ClosedByPullRequests, + } +} + +type IssueUserOutput struct { + Login string `json:"login"` + ID int64 `json:"id,omitempty"` +} + +func issueUserOutput(user *MinimalUser) *IssueUserOutput { + if user == nil { + return nil + } + return &IssueUserOutput{Login: user.Login, ID: user.ID} +} + +type IssueReadCommentOutput struct { + ID int64 `json:"id"` + Body string `json:"body,omitempty"` + HTMLURL string `json:"html_url" jsonschema:"Human-readable comment link."` + User *IssueUserOutput `json:"user,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` + Reactions *MinimalReactions `json:"reactions,omitempty"` + CreatedAt string `json:"created_at,omitempty" jsonschema:"Creation time (RFC 3339)."` + UpdatedAt string `json:"updated_at,omitempty" jsonschema:"Last update time (RFC 3339)."` +} + +func issueCommentOutputs(comments []MinimalIssueComment) []IssueReadCommentOutput { + output := make([]IssueReadCommentOutput, 0, len(comments)) + for _, comment := range comments { + output = append(output, IssueReadCommentOutput{ + ID: comment.ID, Body: comment.Body, HTMLURL: comment.HTMLURL, + User: issueUserOutput(comment.User), AuthorAssociation: comment.AuthorAssociation, + Reactions: comment.Reactions, CreatedAt: comment.CreatedAt, UpdatedAt: comment.UpdatedAt, + }) + } + return output +} + +type IssueParentOutput struct { + Parent *IssueParentRef `json:"parent"` +} + +// These fields keep the original map's lexicographic JSON ordering. +type IssueParentRef struct { + Number int `json:"number"` + Repository string `json:"repository"` + State string `json:"state" jsonschema:"Parent issue state (uppercase GraphQL value)."` + Title string `json:"title"` + URL string `json:"url" jsonschema:"Human-readable parent issue link."` +} + +type IssueLabelsOutput struct { + Labels []IssueLabelOutput `json:"labels"` + TotalCount int `json:"totalCount"` +} + +type IssueWriteOutput struct { + Method string `json:"method"` + Issue *MinimalResponse `json:"issue,omitempty"` + Awaiting *IssueWriteAwaitingOutput `json:"awaiting,omitempty"` +} + +type IssueWriteAwaitingOutput struct { + Status string `json:"status"` + Reason string `json:"reason"` +} + +func (out IssueWriteOutput) MarshalJSON() ([]byte, error) { + if out.Awaiting != nil { + return json.Marshal(out.Awaiting) + } + if out.Method == "" { + return []byte(`{"method":"create","issue":{"id":"","url":""}}`), nil + } + type plain IssueWriteOutput + return json.Marshal(plain(out)) +} + +var issueWriteOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := issueDTOschema[IssueWriteOutput]() + awaiting := issueDTOschema[IssueWriteAwaitingOutput]() + awaiting.Properties["status"].Enum = []any{"awaiting_user_submission"} + delete(schema.Properties, "awaiting") + schema.Properties["status"] = awaiting.Properties["status"] + schema.Properties["reason"] = awaiting.Properties["reason"] + schema.Required = nil + schema.Properties["method"].Enum = []any{"create", "update"} + schema.OneOf = []*jsonschema.Schema{ + {Required: []string{"method", "issue"}, Properties: map[string]*jsonschema.Schema{ + "status": forbiddenIssueVariantProperty(), "reason": forbiddenIssueVariantProperty(), + }}, + {Required: []string{"status", "reason"}, Properties: map[string]*jsonschema.Schema{ + "method": forbiddenIssueVariantProperty(), "issue": forbiddenIssueVariantProperty(), + }}, + } + return schema +}) + +func issueWriteResult(method string) func(*mcp.CallToolResult, *MinimalResponse, error) (*mcp.CallToolResult, *IssueWriteOutput, error) { + return func(result *mcp.CallToolResult, output *MinimalResponse, err error) (*mcp.CallToolResult, *IssueWriteOutput, error) { + if output == nil { + return result, nil, err + } + return result, &IssueWriteOutput{Method: method, Issue: output}, err + } +} + +func issueWriteFieldVariants() []*jsonschema.Schema { + return []*jsonschema.Schema{ + { + Required: []string{"value"}, + Properties: map[string]*jsonschema.Schema{ + "field_option_name": {Type: "string", Enum: []any{""}}, "delete": {Type: "boolean", Enum: []any{false}}, + }, + }, + { + Required: []string{"field_option_name"}, + Properties: map[string]*jsonschema.Schema{ + "field_option_name": {Type: "string", MinLength: new(1)}, + "value": forbiddenIssueVariantProperty(), "delete": {Type: "boolean", Enum: []any{false}}, + }, + }, + { + Required: []string{"delete"}, + Properties: map[string]*jsonschema.Schema{ + "delete": {Type: "boolean", Enum: []any{true}}, "value": forbiddenIssueVariantProperty(), "field_option_name": {Type: "string", Enum: []any{""}}, + }, + }, + } +} + +func forbiddenIssueVariantProperty() *jsonschema.Schema { + // An annotation keeps jsonschema-go from simplifying the universal + // schema to a boolean, which schema-object-only clients cannot decode. + return &jsonschema.Schema{Not: &jsonschema.Schema{Description: "Any value."}} +} + +type IssueLabelOutput struct { + Color string `json:"color"` + Description string `json:"description"` + ID string `json:"id"` + Name string `json:"name"` +} + +type SubIssueOutput struct { + ID *int64 `json:"id,omitempty"` + Number *int `json:"number,omitempty"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + State *string `json:"state,omitempty" jsonschema:"Issue state (lowercase REST value)."` + StateReason *string `json:"state_reason,omitempty"` + HTMLURL *string `json:"html_url,omitempty" jsonschema:"Human-readable issue link."` + User *IssueUserOutput `json:"user,omitempty"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees"` + Milestone string `json:"milestone,omitempty"` + Comments *int `json:"comments,omitempty" jsonschema:"Number of comments."` + CreatedAt string `json:"created_at,omitempty" jsonschema:"Creation time (RFC 3339)."` + UpdatedAt string `json:"updated_at,omitempty" jsonschema:"Last update time (RFC 3339)."` + ClosedAt string `json:"closed_at,omitempty" jsonschema:"Closure time (RFC 3339), when closed."` + IssueType string `json:"issue_type,omitempty"` + SubIssuesSummary *MinimalSubIssuesSummary `json:"sub_issues_summary,omitempty"` + FieldValues []*SubIssueFieldValueOutput `json:"field_values,omitempty"` +} + +type SubIssueFieldValueOutput struct { + IssueFieldID int64 `json:"issue_field_id"` + DataType string `json:"data_type" jsonschema:"REST issue field type."` + Value *IssueFieldValue `json:"value" jsonschema:"Scalar field value; dates use YYYY-MM-DD."` +} + +func subIssueOutput(issue *github.SubIssue) (*SubIssueOutput, error) { + if issue == nil { + return nil, nil + } + minimal := convertToMinimalIssue((*github.Issue)(issue)) + out := &SubIssueOutput{ + ID: issue.ID, Number: issue.Number, State: issue.State, StateReason: issue.StateReason, + Title: issue.Title, Body: issue.Body, HTMLURL: issue.HTMLURL, + User: issueUserOutput(minimal.User), Labels: minimal.Labels, Assignees: minimal.Assignees, + Milestone: minimal.Milestone, Comments: issue.Comments, + CreatedAt: minimal.CreatedAt, UpdatedAt: minimal.UpdatedAt, ClosedAt: minimal.ClosedAt, + IssueType: minimal.IssueType, + } + if summary := issue.SubIssuesSummary; summary != nil { + out.SubIssuesSummary = &MinimalSubIssuesSummary{ + Total: summary.GetTotal(), Completed: summary.GetCompleted(), PercentCompleted: summary.GetPercentCompleted(), + } + } + for _, field := range issue.IssueFieldValues { + if field == nil { + out.FieldValues = append(out.FieldValues, nil) + continue + } + value, err := issueFieldValue(field.Value) + if err != nil { + return nil, err + } + out.FieldValues = append(out.FieldValues, &SubIssueFieldValueOutput{ + IssueFieldID: field.IssueFieldID, DataType: field.DataType, Value: value, + }) + } + return out, nil +} + +var issueReadOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := issueDTOschema[IssueReadOutput]() + issue := schema.Properties["issue"] + issue.Properties["state"].Enum = []any{"", "open", "closed"} + issue.Properties["state_reason"].Enum = []any{"completed", "not_planned", "reopened", "duplicate"} + describeIssueSummarySchema(issue.Properties["sub_issues_summary"]) + schema.Properties["sub_issues"] = subIssueArraySchema() + schema.Properties["parent"].Properties["state"].Enum = []any{"", "OPEN", "CLOSED"} + schema.Properties["method"].Enum = []any{"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"} + methods := []string{"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"} + fields := []string{"issue", "comments", "sub_issues", "parent", "labels"} + for i, method := range methods { + variant := &jsonschema.Schema{Required: []string{fields[i]}, Properties: map[string]*jsonschema.Schema{ + "method": {Enum: []any{method}}, + }} + for j, field := range fields { + if i != j { + variant.Properties[field] = forbiddenIssueVariantProperty() + } + } + if method == "get_labels" { + variant.Required = append(variant.Required, "totalCount") + } else { + variant.Properties["totalCount"] = forbiddenIssueVariantProperty() + } + schema.OneOf = append(schema.OneOf, variant) + } + return schema +}) + +type SubIssueWriteOutput struct { + Method string `json:"method"` + Issue *SubIssueOutput `json:"issue"` +} + +func (out SubIssueWriteOutput) MarshalJSON() ([]byte, error) { + // Unlike the SDK's default nil-pointer handling, a null API response + // must remain null rather than becoming an empty sub-issue object. + if out.Method == "" { + return []byte(`{"method":"add","issue":null}`), nil + } + type plain SubIssueWriteOutput + return json.Marshal(plain(out)) +} + +func subIssueWriteResult(method string) func(*mcp.CallToolResult, *SubIssueOutput, error) (*mcp.CallToolResult, *SubIssueWriteOutput, error) { + return func(result *mcp.CallToolResult, output *SubIssueOutput, err error) (*mcp.CallToolResult, *SubIssueWriteOutput, error) { + return result, &SubIssueWriteOutput{Method: method, Issue: output}, err + } +} + +var subIssueWriteOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := issueDTOschema[SubIssueWriteOutput]() + schema.Properties["method"].Enum = []any{"add", "remove", "reprioritize"} + schema.Properties["issue"] = subIssueSchema() + schema.Properties["issue"].Type = "" + schema.Properties["issue"].Types = []string{"object", "null"} + return schema +}) + +func subIssueSchema() *jsonschema.Schema { + schema := issueDTOschema[SubIssueOutput]() + schema.Properties["state"].Enum = []any{"open", "closed"} + schema.Properties["state_reason"].Enum = []any{"completed", "not_planned", "reopened", "duplicate"} + schema.Properties["field_values"].Items.Properties["data_type"].Enum = []any{"text", "number", "date", "single_select"} + describeIssueSummarySchema(schema.Properties["sub_issues_summary"]) + replaceIssueFieldScalar(schema) + return schema +} + +func describeIssueSummarySchema(schema *jsonschema.Schema) { + schema.Properties["total"].Description = "Total number of sub-issues." + schema.Properties["completed"].Description = "Number of completed sub-issues." + schema.Properties["percent_completed"].Description = "Completed sub-issues as a percentage (0-100)." +} + +func subIssueArraySchema() *jsonschema.Schema { + items := subIssueSchema() + items.Type = "" + items.Types = []string{"object", "null"} + return &jsonschema.Schema{Types: []string{"array", "null"}, Items: items} +} + +func replaceIssueFieldScalar(schema *jsonschema.Schema) { + // jsonschema's type override is not needed for the surrounding API + // definitions; replace only the custom scalar's inferred properties. + if schema == nil { + return + } + if fields := schema.Properties["field_values"]; fields != nil && fields.Items != nil { + fields.Items.Properties["value"] = &jsonschema.Schema{Types: []string{"string", "number", "boolean", "null"}} + } + + replaceIssueFieldScalar(schema.Items) +} + +func issueDTOschema[T any]() *jsonschema.Schema { + schema, err := jsonschema.For[T](nil) + if err != nil { + panic(err) + } + return schema +} + +func normalizeConsolidatedIssueArguments(kind string) func(json.RawMessage) (json.RawMessage, error) { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + for _, field := range []string{"method", "owner", "repo"} { + if _, err := RequiredParam[string](args, field); err != nil { + return nil, err + } + } + requiredInts := []string{"issue_number"} + switch kind { + case "write": + requiredInts = nil + case "sub": + requiredInts = append(requiredInts, "sub_issue_id") + } + for _, field := range requiredInts { + value, err := RequiredInt(args, field) + if err != nil { + return nil, err + } + args[field] = value + } + if kind == "read" { + pagination, err := OptionalPaginationParams(args) + if err != nil { + return nil, err + } + args["page"], args["perPage"] = pagination.Page, pagination.PerPage + } + if kind == "sub" { + if _, err := OptionalParam[bool](args, "replace_parent"); err != nil { + return nil, err + } + for _, field := range []string{"after_id", "before_id"} { + if _, exists := args[field]; exists { + value, err := OptionalIntParam(args, field) + if err != nil { + return nil, err + } + args[field] = value + } + } + } + if kind == "write" { + for _, field := range []string{"title", "body"} { + if _, err := OptionalParam[string](args, field); err != nil { + return nil, err + } + } + for _, field := range []string{"assignees", "labels"} { + if _, err := OptionalStringArrayParam(args, field); err != nil { + return nil, err + } + if args[field] == nil { + delete(args, field) + } + } + milestone, err := OptionalIntParam(args, "milestone") + if err != nil { + return nil, err + } + if _, exists := args["milestone"]; exists { + args["milestone"] = milestone + } + if _, _, err := OptionalNullableStringParam(args, "type"); err != nil { + return nil, err + } + state, err := OptionalParam[string](args, "state") + if err != nil { + return nil, err + } + reason, err := OptionalParam[string](args, "state_reason") + if err != nil { + return nil, err + } + duplicate, err := OptionalIntParam(args, "duplicate_of") + if err != nil { + return nil, err + } + if _, exists := args["duplicate_of"]; exists { + args["duplicate_of"] = duplicate + } + if duplicate != 0 && reason != "duplicate" { + return nil, fmt.Errorf("duplicate_of can only be used when state_reason is 'duplicate'") + } + if err := validateDuplicateState(state, reason, duplicate); err != nil { + return nil, err + } + parent, err := OptionalIntParam(args, "parent_issue_number") + if err != nil { + return nil, err + } + _, parentProvided := args["parent_issue_number"] + if parentProvided { + args["parent_issue_number"] = parent + if parent < 1 { + return nil, fmt.Errorf("parent_issue_number must be greater than 0") + } + if args["method"] != "create" { + return nil, fmt.Errorf("parent_issue_number can only be used with the create method") + } + } + parentOwner, err := OptionalParam[string](args, "parent_owner") + if err != nil { + return nil, err + } + parentRepo, err := OptionalParam[string](args, "parent_repo") + if err != nil { + return nil, err + } + if err := validateParentRepository(parentProvided, parentOwner, parentRepo); err != nil { + return nil, err + } + fields, err := optionalIssueWriteFields(args) + if err != nil { + return nil, err + } + if parentProvided && len(fields) > 0 { + return nil, fmt.Errorf("issue_fields cannot be used with parent_issue_number") + } + if args["method"] != "update" { + delete(args, "issue_number") + } else if _, exists := args["issue_number"]; exists { + value, err := OptionalIntParam(args, "issue_number") + if err != nil { + return nil, err + } + args["issue_number"] = value + } + } + return json.Marshal(args) + } +} diff --git a/pkg/github/consolidated_pull_request_types.go b/pkg/github/consolidated_pull_request_types.go new file mode 100644 index 0000000000..6d6abaed63 --- /dev/null +++ b/pkg/github/consolidated_pull_request_types.go @@ -0,0 +1,627 @@ +package github + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + "strings" + + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/go-viper/mapstructure/v2" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type PullRequestReadInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + PullNumber int `json:"pullNumber"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` + After *string `json:"after,omitempty"` +} + +type CreatePullRequestInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + Head *string `json:"head,omitempty"` + Base *string `json:"base,omitempty"` + Draft *bool `json:"draft,omitempty"` + MaintainerCanModify *bool `json:"maintainer_can_modify,omitempty"` + Reviewers *[]string `json:"reviewers,omitempty"` + UISubmitted *bool `json:"_ui_submitted,omitempty"` +} + +// UpdatePullRequestInput uses pointers because the handler distinguishes +// omitted fields from explicit zero values such as draft=false. +type UpdatePullRequestInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + PullNumber int `json:"pullNumber"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + State *string `json:"state,omitempty"` + Draft *bool `json:"draft,omitempty"` + Base *string `json:"base,omitempty"` + MaintainerCanModify *bool `json:"maintainer_can_modify,omitempty"` + Reviewers *[]string `json:"reviewers,omitempty"` + UISubmitted *bool `json:"_ui_submitted,omitempty"` +} + +type MergePullRequestInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + PullNumber int `json:"pullNumber"` + CommitTitle *string `json:"commit_title,omitempty"` + CommitMessage *string `json:"commit_message,omitempty"` + MergeMethod *string `json:"merge_method,omitempty"` + ExpectedHeadSHA *string `json:"expectedHeadSha,omitempty"` +} + +type UpdatePullRequestBranchInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + PullNumber int `json:"pullNumber"` + ExpectedHeadSHA *string `json:"expectedHeadSha,omitempty"` +} + +// PullRequestReviewWriteInput covers both feature variants; resolutionReason +// is advertised and used only by the resolution-reason variant. +type PullRequestReviewWriteInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + PullNumber int `json:"pullNumber"` + Body string `json:"body,omitempty"` + Event string `json:"event,omitempty"` + CommitID *string `json:"commitID,omitempty"` + ThreadID string `json:"threadId,omitempty"` + ResolutionReason *string `json:"resolutionReason,omitempty"` +} + +type AddCommentToPendingReviewInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + PullNumber int `json:"pullNumber"` + Path string `json:"path"` + Body string `json:"body"` + SubjectType string `json:"subjectType"` + Line *int `json:"line,omitempty"` + Side *string `json:"side,omitempty"` + StartLine *int `json:"startLine,omitempty"` + StartSide *string `json:"startSide,omitempty"` +} + +type AddReplyToPullRequestCommentInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + PullNumber *int `json:"pullNumber,omitempty"` + CommentID int64 `json:"commentId"` + Body *string `json:"body,omitempty"` + Reaction *string `json:"reaction,omitempty"` +} + +// PullRequestReadOutput mirrors the method-specific top-level shapes that +// pull_request_read has always returned as text. +type PullRequestReadOutput struct { + PullRequest *MinimalPullRequest + Diff *PullRequestDiffOutput + Status *MinimalCombinedStatus + Files *[]MinimalPRFile + Commits *[]MinimalPullRequestCommit + ReviewThreads *MinimalReviewThreadsResponse + Reviews *[]MinimalPullRequestReview + Comments *[]IssueReadCommentOutput + CheckRuns *MinimalCheckRunsResult +} + +// PullRequestDiffOutput wraps the raw diff, which the text content returns verbatim. +type PullRequestDiffOutput struct { + Diff string `json:"diff"` +} + +func (out PullRequestReadOutput) MarshalJSON() ([]byte, error) { + switch { + case out.PullRequest != nil: + return json.Marshal(out.PullRequest) + case out.Diff != nil: + return json.Marshal(out.Diff) + case out.Status != nil: + return json.Marshal(out.Status) + case out.Files != nil: + return json.Marshal(out.Files) + case out.Commits != nil: + return json.Marshal(out.Commits) + case out.ReviewThreads != nil: + return json.Marshal(out.ReviewThreads) + case out.Reviews != nil: + return json.Marshal(out.Reviews) + case out.Comments != nil: + return json.Marshal(out.Comments) + case out.CheckRuns != nil: + return json.Marshal(out.CheckRuns) + default: + // The SDK validates this zero value even for error results before the + // protocol middleware removes it from the wire result. + return []byte("null"), nil + } +} + +func pullRequestCommentsOutput(output *IssueReadOutput) *PullRequestReadOutput { + if output == nil || output.Comments == nil { + return nil + } + return &PullRequestReadOutput{Comments: output.Comments} +} + +func pullRequestReadOutputSchema() *jsonschema.Schema { + arrays := []*jsonschema.Schema{ + pullRequestOutputSchema[[]MinimalPRFile](), + pullRequestOutputSchema[[]MinimalPullRequestCommit](), + pullRequestOutputSchema[[]MinimalPullRequestReview](), + pullRequestOutputSchema[[]IssueReadCommentOutput](), + } + for _, array := range arrays { + array.Defs = nil + } + // An empty array is valid for every list method, so the list shapes form + // one anyOf array variant instead of ambiguous oneOf branches. + return repositoryUnionSchema( + &jsonschema.Schema{Type: "null"}, + pullRequestOutputSchema[MinimalPullRequest](), + pullRequestOutputSchema[PullRequestDiffOutput](), + pullRequestOutputSchema[MinimalCombinedStatus](), + pullRequestOutputSchema[MinimalReviewThreadsResponse](), + pullRequestOutputSchema[MinimalCheckRunsResult](), + &jsonschema.Schema{Type: "array", AnyOf: arrays}, + ) +} + +// PullRequestWriteOutput is either the created/updated pull request reference +// or the notice that an interactive form is awaiting user submission. +type PullRequestWriteOutput struct { + PullRequest *PullRequestMutationReference + Awaiting *IssueWriteAwaitingOutput +} + +func (out PullRequestWriteOutput) MarshalJSON() ([]byte, error) { + if out.Awaiting != nil { + return json.Marshal(out.Awaiting) + } + if out.PullRequest != nil { + return json.Marshal(out.PullRequest) + } + return json.Marshal(PullRequestMutationReference{}) +} + +func pullRequestWriteOutputSchema() *jsonschema.Schema { + awaiting := pullRequestOutputSchema[IssueWriteAwaitingOutput]() + awaiting.Properties["status"].Enum = []any{"awaiting_user_submission"} + return repositoryUnionSchema(pullRequestOutputSchema[PullRequestMutationReference](), awaiting) +} + +// PullRequestMutationReference includes a browser URL only when the API supplied one. +// Reactions have no browser URL; their API endpoint is not useful output. +type PullRequestMutationReference struct { + ID string `json:"id"` + HTMLURL string `json:"html_url,omitempty"` +} + +func pullRequestMutationReference(response MinimalResponse) *PullRequestMutationReference { + return &PullRequestMutationReference{ID: response.ID, HTMLURL: response.URL} +} + +func pullRequestFormArguments(req *mcp.CallToolRequest, args map[string]any) (map[string]any, error) { + // Form deferral depends on which parameters the caller actually sent, + // so inspect the raw request rather than the normalized typed input. + if req == nil || req.Params == nil || len(req.Params.Arguments) == 0 { + return args, nil + } + var formArgs map[string]any + if err := json.Unmarshal(req.Params.Arguments, &formArgs); err != nil { + return nil, err + } + return formArgs, nil +} + +func pullRequestAwaitingFormResult(message string) (*mcp.CallToolResult, *PullRequestWriteOutput, error) { + result := utils.NewToolResultAwaitingFormSubmission(message) + output := &IssueWriteAwaitingOutput{ + Status: "awaiting_user_submission", + Reason: "An interactive form is being shown to the user. The operation has not been performed.", + } + result.StructuredContent = output + return result, &PullRequestWriteOutput{Awaiting: output}, nil +} + +// PullRequestMergeOutput preserves a JSON null merge response rather than +// letting the SDK replace a nil pointer with an empty object. +type PullRequestMergeOutput struct { + Result *PullRequestMergeResult +} + +type PullRequestMergeResult struct { + SHA *string `json:"sha,omitempty"` + Merged *bool `json:"merged,omitempty"` + Message *string `json:"message,omitempty"` +} + +func (out PullRequestMergeOutput) MarshalJSON() ([]byte, error) { + return json.Marshal(out.Result) +} + +func pullRequestMergeOutputSchema() *jsonschema.Schema { + return nullableRepositoryOutputSchema[PullRequestMergeResult]() +} + +// PullRequestBranchUpdateOutput carries either the API response or, for an +// accepted asynchronous update, the in-progress message. +type PullRequestBranchUpdateOutput struct { + Result *PullRequestBranchUpdateResult +} + +type PullRequestBranchUpdateResult struct { + Message *string `json:"message,omitempty"` +} + +func (out PullRequestBranchUpdateOutput) MarshalJSON() ([]byte, error) { + return json.Marshal(out.Result) +} + +func pullRequestBranchUpdateOutputSchema() *jsonschema.Schema { + return nullableRepositoryOutputSchema[PullRequestBranchUpdateResult]() +} + +func nullableRepositoryOutputSchema[T any]() *jsonschema.Schema { + schema := pullRequestOutputSchema[T]() + schema.Type = "" + schema.Types = []string{"object", "null"} + return schema +} + +// PullRequestCommentReplyOutput is a single reply or reaction reference, or +// both when the caller requested a reply and a reaction together. +type PullRequestCommentReplyOutput struct { + Response *PullRequestMutationReference + ReplyAndReaction *PullRequestReplyAndReactionOutput +} + +// PullRequestReplyAndReactionOutput keeps the legacy map's sorted key order. +type PullRequestReplyAndReactionOutput struct { + Comment PullRequestMutationReference `json:"comment"` + Reaction PullRequestMutationReference `json:"reaction"` +} + +func (out PullRequestCommentReplyOutput) MarshalJSON() ([]byte, error) { + if out.ReplyAndReaction != nil { + return json.Marshal(out.ReplyAndReaction) + } + if out.Response != nil { + return json.Marshal(out.Response) + } + return json.Marshal(PullRequestMutationReference{}) +} + +func pullRequestCommentReplyOutputSchema() *jsonschema.Schema { + return repositoryUnionSchema( + pullRequestOutputSchema[PullRequestMutationReference](), + pullRequestOutputSchema[PullRequestReplyAndReactionOutput](), + ) +} + +func pullRequestOutputSchema[T any]() *jsonschema.Schema { + schema := repositoryOutputSchema[T]() + describePullRequestOutputSchema(schema) + return schema +} + +// Only newly inferred schemas are customized, once during tool construction. +func describePullRequestOutputSchema(schema *jsonschema.Schema) { + if schema == nil { + return + } + for name, field := range schema.Properties { + switch name { + case "html_url": + field.Description = "Browser URL." + case "sha", "commit_id": + field.Description = "Git commit SHA." + case "created_at", "updated_at", "closed_at", "merged_at", "submitted_at", "started_at", "completed_at": + field.Description = "RFC 3339 timestamp." + case "line", "original_line", "start_line", "original_start_line": + field.Description = "One-based diff line number." + case "additions", "deletions", "changes": + field.Description = "Number of lines." + case "total_count", "totalCount": + field.Description = "Total number of results." + case "message": + if schema.Properties["author"] == nil { + field.Description = "Operation result message." + } + case "merged": + field.Description = "Whether the pull request was merged." + case "diff": + field.Description = "Unified pull request diff." + case "state": + switch { + case schema.Properties["draft"] != nil: + field.Description = "Pull request state; empty for a sparse response." + field.Enum = []any{"open", "closed", ""} + case schema.Properties["statuses"] != nil || schema.Properties["context"] != nil: + field.Description = "Commit status; empty for a sparse response." + field.Enum = []any{"error", "failure", "pending", "success", ""} + case schema.Properties["commit_id"] != nil: + field.Description = "Review state; empty for a sparse response." + field.Enum = []any{"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED", "PENDING", ""} + } + case "status": + switch { + case schema.Properties["filename"] != nil: + field.Description = "File change status." + field.Enum = []any{"added", "removed", "modified", "renamed", "copied", "changed", "unchanged"} + case schema.Properties["conclusion"] != nil: + field.Description = "Check execution status; empty for a sparse response." + field.Enum = []any{"queued", "in_progress", "completed", "waiting", "requested", "pending", ""} + } + case "conclusion": + field.Description = "Completed check outcome." + field.Enum = []any{"success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required", "stale", "startup_failure"} + } + describePullRequestOutputSchema(field) + } + describePullRequestOutputSchema(schema.Items) + for _, definition := range schema.Defs { + describePullRequestOutputSchema(definition) + } + for _, variant := range append(slices.Clone(schema.OneOf), schema.AnyOf...) { + describePullRequestOutputSchema(variant) + } +} + +// pullRequestMessageResult exposes the success message of review mutations +// whose exported helpers only return a text result. +func pullRequestMessageResult(result *mcp.CallToolResult, err error) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + if err != nil || result == nil || result.IsError || len(result.Content) != 1 { + return result, nil, err + } + text, ok := result.Content[0].(*mcp.TextContent) + if !ok { + return result, nil, nil + } + return result, &RepositoryMessageOutput{Message: text.Text}, nil +} + +// pullRequestArgumentSpec lists parameters in the order the legacy handler +// read them, so normalization reports the same first error the handler did. +type pullRequestArgumentSpec struct { + required []string + requiredInts []string + ints []string + strings []string + bools []string + stringArrays []string + ignoredStrings []string + // deferred required strings are filled with "" so the handler can still + // show its interactive form before reporting them as missing. + deferred []string + methods []string +} + +var pullRequestArgumentSpecs = map[string]pullRequestArgumentSpec{ + "read": { + required: []string{"method", "owner", "repo"}, requiredInts: []string{"pullNumber"}, + ints: []string{"page", "perPage"}, + methods: []string{"get", "get_diff", "get_status", "get_files", "get_commits", "get_review_comments", "get_reviews", "get_comments", "get_check_runs"}, + }, + "create": { + required: []string{"owner", "repo"}, deferred: []string{"title", "head", "base"}, + strings: []string{"title", "head", "base", "body"}, bools: []string{"draft", "maintainer_can_modify"}, + stringArrays: []string{"reviewers"}, + }, + "update": { + required: []string{"owner", "repo"}, requiredInts: []string{"pullNumber"}, + strings: []string{"title", "body", "state", "base"}, bools: []string{"draft", "maintainer_can_modify"}, + stringArrays: []string{"reviewers"}, + }, + "merge": { + required: []string{"owner", "repo"}, requiredInts: []string{"pullNumber"}, + strings: []string{"commit_title", "commit_message", "merge_method", "expectedHeadSha"}, + }, + "branch": { + required: []string{"owner", "repo"}, requiredInts: []string{"pullNumber"}, strings: []string{"expectedHeadSha"}, + }, + "pending_comment": { + required: []string{"owner", "repo"}, requiredInts: []string{"pullNumber"}, + ints: []string{"line", "startLine"}, ignoredStrings: []string{"side", "startSide"}, + }, + "reply": { + required: []string{"owner", "repo"}, strings: []string{"body", "reaction"}, + }, +} + +// normalizePullRequestArguments replays the legacy handler's parameter +// checks before strict schema validation. Legacy handlers bypassed schema +// validation, so they accepted numeric strings and whole floats and reported +// their own error messages; those messages are preserved here. +func normalizePullRequestArguments(kind string) func(json.RawMessage) (json.RawMessage, error) { + spec, ok := pullRequestArgumentSpecs[kind] + if !ok { + panic("unknown pull request argument kind: " + kind) + } + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + for _, field := range spec.required { + if _, err := RequiredParam[string](args, field); err != nil { + return nil, err + } + } + for _, field := range spec.requiredInts { + value, err := RequiredInt(args, field) + if err != nil { + return nil, err + } + args[field] = value + } + if kind == "pending_comment" { + for _, field := range []string{"path", "body", "subjectType"} { + if _, err := RequiredParam[string](args, field); err != nil { + return nil, err + } + } + } + if kind == "reply" { + commentID, err := RequiredBigInt(args, "commentId") + if err != nil { + return nil, err + } + if commentID < 1 { + return nil, fmt.Errorf("commentId must be greater than 0") + } + args["commentId"] = commentID + } + for _, field := range spec.deferred { + if _, exists := args[field]; !exists { + args[field] = "" + } + } + + stringFields := spec.strings + boolFields := spec.bools + stringArrayFields := spec.stringArrays + if kind == "reply" { + stringFields = []string{"body", "reaction"} + } + if kind == "create" || kind == "update" { + // This flag is inspected from the raw call only to control form + // deferral; legacy handlers never type-validated it. + if _, err := OptionalParam[bool](args, "_ui_submitted"); err != nil { + delete(args, "_ui_submitted") + } + } + for _, field := range stringFields { + if _, err := OptionalParam[string](args, field); err != nil { + return nil, err + } + } + for _, field := range boolFields { + if _, err := OptionalParam[bool](args, field); err != nil { + return nil, err + } + } + for _, field := range stringArrayFields { + if _, exists := args[field]; !exists { + continue + } + values, err := OptionalStringArrayParam(args, field) + if err != nil { + return nil, err + } + args[field] = values + } + for _, field := range spec.ints { + if _, exists := args[field]; !exists { + continue + } + value, err := OptionalIntParam(args, field) + if err != nil { + return nil, err + } + args[field] = value + } + if spec.methods != nil && !slices.Contains(spec.methods, args["method"].(string)) { + return nil, fmt.Errorf("unknown method: %s", args["method"]) + } + if kind == "read" { + if args["method"] != "get_review_comments" { + delete(args, "after") + } else if _, err := OptionalParam[string](args, "after"); err != nil { + return nil, err + } + } + if kind == "reply" { + _, hasBody := args["body"] + _, hasReaction := args["reaction"] + if !hasBody && !hasReaction { + return nil, fmt.Errorf("at least one of body or reaction is required") + } + // pullNumber is only read, and validated, when a reply body is sent. + if value, exists := args["pullNumber"]; exists { + pullNumber, err := toInt(value) + switch { + case err == nil && hasBody: + args["pullNumber"] = pullNumber + case err != nil && hasBody: + return nil, fmt.Errorf("parameter pullNumber is not a valid number: %w", err) + default: + delete(args, "pullNumber") + } + } + if hasBody && args["body"] == "" { + return nil, fmt.Errorf("body cannot be empty when provided") + } + if hasReaction && args["reaction"] == "" { + return nil, fmt.Errorf("reaction cannot be empty when provided") + } + } + for _, field := range spec.ignoredStrings { + if _, isString := args[field].(string); !isString { + delete(args, field) + } + } + return json.Marshal(args) + } +} + +// normalizePullRequestReviewWriteArguments retains the legacy WeakDecode +// semantics: case-insensitive keys, numeric-string conversion and fractional +// truncation of pullNumber. +func normalizePullRequestReviewWriteArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + var params PullRequestReviewWriteParams + if err := mapstructure.WeakDecode(args, ¶ms); err != nil { + return nil, err + } + if !slices.Contains([]string{"create", "submit_pending", "delete_pending", "resolve_thread", "unresolve_thread"}, params.Method) { + return nil, fmt.Errorf("unknown method: %s", params.Method) + } + canonical := map[string]any{ + "method": params.Method, "owner": params.Owner, "repo": params.Repo, "pullNumber": params.PullNumber, + } + // Empty optional strings are equivalent to omitted ones for the handler, + // but would fail the schema's enum for event. + for key, value := range map[string]string{"body": params.Body, "event": params.Event, "threadId": params.ThreadID} { + if value != "" { + canonical[key] = value + } + } + if params.CommitID != nil { + canonical["commitID"] = *params.CommitID + } + if params.ResolutionReason != nil { + canonical["resolutionReason"] = *params.ResolutionReason + } + for key := range args { + for name := range pullRequestReviewWriteFields { + if strings.EqualFold(key, name) { + delete(args, key) + } + } + } + maps.Copy(args, canonical) + return json.Marshal(args) +} + +var pullRequestReviewWriteFields = map[string]struct{}{ + "method": {}, "owner": {}, "repo": {}, "pullNumber": {}, "body": {}, "event": {}, + "commitID": {}, "threadId": {}, "resolutionReason": {}, +} diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index da1a42d694..93f9eed04c 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -1,16 +1,19 @@ package github import ( + "bytes" "context" "encoding/json" + "fmt" + "strings" "time" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -19,6 +22,74 @@ import ( // GetMeUIResourceURI is the URI for the get_me tool's MCP App UI resource. const GetMeUIResourceURI = "ui://github-mcp-server/get-me" +type GetMeInput struct{} + +type GetTeamsInput struct { + User string `json:"user,omitempty" jsonschema:"Username to get teams for. If not provided, uses the authenticated user."` +} + +type GetTeamMembersInput struct { + Org string `json:"org" jsonschema:"Organization login (owner) that contains the team."` + TeamSlug string `json:"team_slug" jsonschema:"Team slug"` +} + +func contextToolInputSchema[In any](descriptions map[string]string) *jsonschema.Schema { + schema, err := jsonschema.For[In](nil) + if err != nil { + panic(fmt.Sprintf("failed to generate context tool input schema: %v", err)) + } + if schema.Properties == nil { + schema.Properties = make(map[string]*jsonschema.Schema) + } + schema.AdditionalProperties = nil + for name, description := range descriptions { + schema.Properties[name].Description = description + } + return schema +} + +func contextToolValidationSchema(schema *jsonschema.Schema) *jsonschema.Schema { + validationSchema := schema.CloneSchemas() + validationSchema.AdditionalProperties = &jsonschema.Schema{} + return validationSchema +} + +func normalizeGetTeamsInput(arguments json.RawMessage) (json.RawMessage, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(arguments, &fields); err != nil { + return nil, err + } + if user, exists := fields["user"]; exists && bytes.Equal(bytes.TrimSpace(user), []byte("null")) { + return nil, &inventory.ToolInputError{Message: "parameter user is not of type string, is "} + } + return normalizeContextRoutingKeys(arguments, fields, "user") +} + +func normalizeGetTeamMembersInput(arguments json.RawMessage) (json.RawMessage, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(arguments, &fields); err != nil { + return nil, err + } + return normalizeContextRoutingKeys(arguments, fields, "org", "team_slug") +} + +func normalizeContextRoutingKeys(arguments json.RawMessage, fields map[string]json.RawMessage, routingKeys ...string) (json.RawMessage, error) { + changed := false + for name := range fields { + for _, key := range routingKeys { + if name != key && strings.EqualFold(name, key) { + delete(fields, name) + changed = true + break + } + } + } + if !changed { + return arguments, nil + } + return json.Marshal(fields) +} + // 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 { @@ -34,8 +105,8 @@ type UserDetails struct { PublicGists int `json:"public_gists"` Followers int `json:"followers"` Following int `json:"following"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at" jsonschema:"Account creation time in RFC3339 format."` + UpdatedAt time.Time `json:"updated_at" jsonschema:"Last profile update time in RFC3339 format."` PrivateGists int `json:"private_gists,omitempty"` TotalPrivateRepos int64 `json:"total_private_repos,omitempty"` OwnedPrivateRepos int64 `json:"owned_private_repos,omitempty"` @@ -43,7 +114,8 @@ type UserDetails struct { // GetMe creates a tool to get details of the authenticated user. func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + inputSchema := contextToolInputSchema[GetMeInput](nil) + return NewToolWithSchemaOptions[GetMeInput, MinimalUser]( ToolsetMetadataContext, mcp.Tool{ Name: "get_me", @@ -52,9 +124,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_GET_ME_USER_TITLE", "Get my user profile"), ReadOnlyHint: true, }, - // 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":{}}`), + InputSchema: inputSchema, Meta: mcp.Meta{ "ui": map[string]any{ "resourceUri": GetMeUIResourceURI, @@ -63,10 +133,13 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, scopes.NoScopes(), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { + inventory.TypedSchemaOptions{ + ValidationInputSchema: contextToolValidationSchema(inputSchema), + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, _ GetMeInput) (*mcp.CallToolResult, MinimalUser, error) { client, err := deps.GetClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), MinimalUser{}, nil } user, res, err := client.Users.Get(ctx, "") @@ -75,7 +148,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { "failed to get user", res, err, - ), nil, nil + ), MinimalUser{}, nil } // Create minimal user representation instead of returning full user object @@ -107,7 +180,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { result := MarshalledTextResult(minimalUser) result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGetMe()) - return result, nil, nil + return result, minimalUser, nil }, ) } @@ -124,7 +197,10 @@ type OrganizationTeams struct { } func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + inputSchema := contextToolInputSchema[GetTeamsInput](map[string]string{ + "user": t("TOOL_GET_TEAMS_USER_DESCRIPTION", "Username to get teams for. If not provided, uses the authenticated user."), + }) + return NewToolWithSchemaOptions[GetTeamsInput, []OrganizationTeams]( ToolsetMetadataContext, mcp.Tool{ Name: "get_teams", @@ -133,26 +209,16 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_GET_TEAMS_TITLE", "Get teams"), ReadOnlyHint: true, }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "user": { - Type: "string", - Description: t("TOOL_GET_TEAMS_USER_DESCRIPTION", "Username to get teams for. If not provided, uses the authenticated user."), - }, - }, - }, + InputSchema: inputSchema, }, scopes.RequireAll(scopes.ReadOrg), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - user, err := OptionalParam[string](args, "user") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - + inventory.TypedSchemaOptions{ + ValidationInputSchema: contextToolValidationSchema(inputSchema), + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GetTeamsInput) (*mcp.CallToolResult, []OrganizationTeams, error) { var username string - if user != "" { - username = user + if args.User != "" { + username = args.User } else { client, err := deps.GetClient(ctx) if err != nil { @@ -221,13 +287,22 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { // outside contributors (trusted). Org team rosters are visible only // to org members, so confidentiality is private. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelTeam()) - return result, nil, nil + if organizations == nil { + organizations = []OrganizationTeams{} + } + return result, organizations, nil }, + normalizeGetTeamsInput, ) } func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + inputSchema := contextToolInputSchema[GetTeamMembersInput](map[string]string{ + "org": t("TOOL_GET_TEAM_MEMBERS_ORG_DESCRIPTION", "Organization login (owner) that contains the team."), + "team_slug": t("TOOL_GET_TEAM_MEMBERS_TEAM_SLUG_DESCRIPTION", "Team slug"), + }) + + return NewToolWithSchemaOptions[GetTeamMembersInput, []string]( ToolsetMetadataContext, mcp.Tool{ Name: "get_team_members", @@ -236,31 +311,18 @@ func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_GET_TEAM_MEMBERS_TITLE", "Get team members"), ReadOnlyHint: true, }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "org": { - Type: "string", - Description: t("TOOL_GET_TEAM_MEMBERS_ORG_DESCRIPTION", "Organization login (owner) that contains the team."), - }, - "team_slug": { - Type: "string", - Description: t("TOOL_GET_TEAM_MEMBERS_TEAM_SLUG_DESCRIPTION", "Team slug"), - }, - }, - Required: []string{"org", "team_slug"}, - }, + InputSchema: inputSchema, }, scopes.RequireAll(scopes.ReadOrg), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - org, err := RequiredParam[string](args, "org") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + inventory.TypedSchemaOptions{ + ValidationInputSchema: contextToolValidationSchema(inputSchema), + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GetTeamMembersInput) (*mcp.CallToolResult, []string, error) { + if args.Org == "" { + return utils.NewToolResultError("missing required parameter: org"), nil, nil } - - teamSlug, err := RequiredParam[string](args, "team_slug") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.TeamSlug == "" { + return utils.NewToolResultError("missing required parameter: team_slug"), nil, nil } gqlClient, err := deps.GetGQLClient(ctx) @@ -280,8 +342,8 @@ func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { } `graphql:"organization(login: $org)"` } vars := map[string]any{ - "org": githubv4.String(org), - "teamSlug": githubv4.String(teamSlug), + "org": githubv4.String(args.Org), + "teamSlug": githubv4.String(args.TeamSlug), } if err := gqlClient.Query(ctx, &q, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get team members", err), nil, nil @@ -297,7 +359,11 @@ func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { // outside contributors (trusted). A team's member roster is visible // only to org members, so confidentiality is private. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelTeam()) - return result, nil, nil + if members == nil { + members = []string{} + } + return result, members, nil }, + normalizeGetTeamMembersInput, ) } diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 0825158abb..041f2d168c 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -7,10 +7,12 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -22,7 +24,7 @@ func Test_GetMe(t *testing.T) { serverTool := GetMe(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testContextToolSnapshot[GetMeInput, MinimalUser](t, tool) // Verify some basic very important properties assert.Equal(t, "get_me", tool.Name) @@ -30,19 +32,19 @@ func Test_GetMe(t *testing.T) { // Setup mock user response mockUser := &github.User{ - Login: github.Ptr("testuser"), - Name: github.Ptr("Test User"), - Email: github.Ptr("test@example.com"), - Bio: github.Ptr("GitHub user for testing"), - Company: github.Ptr("Test Company"), - Location: github.Ptr("Test Location"), - HTMLURL: github.Ptr("https://github.com/testuser"), + Login: new("testuser"), + Name: new("Test User"), + Email: new("test@example.com"), + Bio: new("GitHub user for testing"), + Company: new("Test Company"), + Location: new("Test Location"), + HTMLURL: new("https://github.com/testuser"), CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)}, - Type: github.Ptr("User"), - Hireable: github.Ptr(true), - TwitterUsername: github.Ptr("testuser_twitter"), + Type: new("User"), + Hireable: new(true), + TwitterUsername: new("testuser_twitter"), Plan: &github.Plan{ - Name: github.Ptr("pro"), + Name: new("pro"), }, } @@ -144,8 +146,8 @@ func Test_GetMe_OmittedArguments(t *testing.T) { t.Parallel() mockUser := &github.User{ - Login: github.Ptr("testuser"), - HTMLURL: github.Ptr("https://github.com/testuser"), + Login: new("testuser"), + HTMLURL: new("https://github.com/testuser"), CreatedAt: &github.Timestamp{Time: time.Now()}, } mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -169,14 +171,303 @@ func Test_GetMe_OmittedArguments(t *testing.T) { assert.Equal(t, mockUser.GetHTMLURL(), returnedUser.ProfileURL) } +func TestContextToolsTypedRegistration(t *testing.T) { + mockUser := &github.User{ + Login: new("testuser"), + HTMLURL: new("https://github.com/testuser"), + CreatedAt: &github.Timestamp{Time: time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)}, + } + teamsMatcher := githubv4mock.NewQueryMatcher( + "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}", + map[string]any{"login": "specificuser"}, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "organizations": map[string]any{ + "nodes": []map[string]any{{ + "login": "testorg", + "teams": map[string]any{ + "nodes": []map[string]any{{ + "name": "team1", + "slug": "team1", + "description": "Team 1", + }}, + }, + }}, + }, + }, + }), + ) + membersMatcher := githubv4mock.NewQueryMatcher( + "query($org:String!$teamSlug:String!){organization(login: $org){team(slug: $teamSlug){members(first: 100){nodes{login}}}}}", + map[string]any{"org": "testorg", "teamSlug": "testteam"}, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "team": map[string]any{ + "members": map[string]any{ + "nodes": []map[string]any{{"login": "user1"}, {"login": "user2"}}, + }, + }, + }, + }), + ) + emptyOrganizationsMatcher := githubv4mock.NewQueryMatcher( + "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}", + map[string]any{"login": "no-orgs"}, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{"organizations": map[string]any{"nodes": []any{}}}, + }), + ) + emptyTeamsMatcher := githubv4mock.NewQueryMatcher( + "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}", + map[string]any{"login": "no-teams"}, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{"organizations": map[string]any{"nodes": []any{ + map[string]any{"login": "testorg", "teams": map[string]any{"nodes": []any{}}}, + }}}, + }), + ) + emptyMembersMatcher := githubv4mock.NewQueryMatcher( + "query($org:String!$teamSlug:String!){organization(login: $org){team(slug: $teamSlug){members(first: 100){nodes{login}}}}}", + map[string]any{"org": "testorg", "teamSlug": "emptyteam"}, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{"team": map[string]any{"members": map[string]any{"nodes": []any{}}}}, + }), + ) + mockedGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(teamsMatcher, membersMatcher)) + failGetMe := false + var graphQLCalls int + deps := stubDeps{ + clientFn: stubClientFnFromHTTP(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUser: func(w http.ResponseWriter, r *http.Request) { + if failGetMe { + badRequestHandler("expected typed output test failure")(w, r) + return + } + mockResponse(t, http.StatusOK, mockUser)(w, r) + }, + })), + obsv: stubExporters(), + gqlClientFn: func(context.Context) (*githubv4.Client, error) { + graphQLCalls++ + return mockedGQLClient, nil + }, + } + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + getMeTool := GetMe(translations.NullTranslationHelper) + getTeamsTool := GetTeams(translations.NullTranslationHelper) + teamMembersTool := GetTeamMembers(translations.NullTranslationHelper) + inv, err := inventory.NewBuilder(). + SetTools([]inventory.ServerTool{getMeTool, getTeamsTool, teamMembersTool}). + WithToolsets([]string{"all"}). + Build() + require.NoError(t, err) + inv.RegisterTools(context.Background(), server, deps) + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + clientSession, err := client.Connect(context.Background(), clientTransport, &mcp.ClientSessionOptions{ + ProtocolVersion: inventory.ProtocolVersionMultiRoundTrip, + }) + require.NoError(t, err) + require.Equal(t, inventory.ProtocolVersionMultiRoundTrip, clientSession.InitializeResult().ProtocolVersion) + t.Cleanup(func() { _ = clientSession.Close() }) + + protocolMeta := mcp.Meta{mcp.MetaKeyProtocolVersion: inventory.ProtocolVersionMultiRoundTrip} + list, err := clientSession.ListTools(context.Background(), &mcp.ListToolsParams{Meta: protocolMeta}) + require.NoError(t, err) + require.Len(t, list.Tools, 3) + outputSchemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + assert.NotNil(t, tool.InputSchema) + assert.NotNil(t, tool.OutputSchema) + outputJSON, err := json.Marshal(tool.OutputSchema) + require.NoError(t, err) + var outputSchema jsonschema.Schema + require.NoError(t, json.Unmarshal(outputJSON, &outputSchema)) + outputSchemas[tool.Name], err = outputSchema.Resolve(nil) + require.NoError(t, err) + schemaJSON, err := json.Marshal(tool.InputSchema) + require.NoError(t, err) + var schema struct { + Type string `json:"type"` + Properties map[string]json.RawMessage `json:"properties"` + Required []string `json:"required"` + AnyOf []json.RawMessage `json:"anyOf"` + } + require.NoError(t, json.Unmarshal(schemaJSON, &schema)) + assert.Equal(t, "object", schema.Type, "input roots must remain non-nullable objects") + assert.Empty(t, schema.AnyOf) + var schemaMetadata struct { + AdditionalProperties *bool `json:"additionalProperties"` + } + require.NoError(t, json.Unmarshal(schemaJSON, &schemaMetadata)) + assert.Nil(t, schemaMetadata.AdditionalProperties, "advertised input schema must match the legacy snapshot bytes") + for propertyName, property := range schema.Properties { + var propertySchema jsonschema.Schema + require.NoError(t, json.Unmarshal(property, &propertySchema)) + assert.Empty(t, propertySchema.Enum, "input enums must match the legacy schema") + assert.Nil(t, propertySchema.Minimum) + assert.Nil(t, propertySchema.Maximum) + assert.Nil(t, propertySchema.MinLength, "input bounds must match the legacy schema") + assert.Nil(t, propertySchema.MaxLength) + assert.Nil(t, propertySchema.Default) + switch tool.Name + "." + propertyName { + case "get_teams.user": + assert.Equal(t, "Username to get teams for. If not provided, uses the authenticated user.", propertySchema.Description) + case "get_team_members.org": + assert.Equal(t, "Organization login (owner) that contains the team.", propertySchema.Description) + case "get_team_members.team_slug": + assert.Equal(t, "Team slug", propertySchema.Description) + default: + t.Fatalf("unexpected input property %q on tool %q", propertyName, tool.Name) + } + } + switch tool.Name { + case "get_me": + assert.NotNil(t, schema.Properties, "empty input schemas must retain properties") + assert.Empty(t, schema.Properties) + assert.Empty(t, schema.Required) + case "get_teams": + assert.Contains(t, schema.Properties, "user") + assert.Empty(t, schema.Required, "user must remain optional") + case "get_team_members": + assert.ElementsMatch(t, []string{"org", "team_slug"}, schema.Required) + default: + t.Fatalf("unexpected context tool %q", tool.Name) + } + } + + result, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_me", + Arguments: map[string]any{"legacy_ignored_argument": true}, + Meta: protocolMeta, + }) + require.NoError(t, err) + require.False(t, result.IsError) + require.NotNil(t, result.StructuredContent) + + structuredJSON, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, getTextResult(t, result).Text, string(structuredJSON)) + var returnedUser MinimalUser + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returnedUser)) + legacyText, err := json.Marshal(returnedUser) + require.NoError(t, err) + assert.JSONEq(t, string(legacyText), getTextResult(t, result).Text) + request := createMCPRequest(map[string]any{}) + legacyResult, err := getMeTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.Equal(t, `{"login":"testuser","profile_url":"https://github.com/testuser","details":{"public_repos":0,"public_gists":0,"followers":0,"following":0,"created_at":"2020-01-02T03:04:05Z","updated_at":"0001-01-01T00:00:00Z"}}`, getTextResult(t, legacyResult).Text) + require.NoError(t, outputSchemas["get_me"].Validate(result.StructuredContent)) + + result, err = clientSession.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_teams", + Arguments: map[string]any{"user": "specificuser", "legacy_ignored_argument": true}, + Meta: protocolMeta, + }) + require.NoError(t, err) + require.False(t, result.IsError) + structuredJSON, err = json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, `[{"org":"testorg","teams":[{"name":"team1","slug":"team1","description":"Team 1"}]}]`, string(structuredJSON)) + assert.JSONEq(t, string(structuredJSON), getTextResult(t, result).Text) + request = createMCPRequest(map[string]any{"user": "specificuser"}) + legacyResult, err = getTeamsTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.Equal(t, `[{"org":"testorg","teams":[{"name":"team1","slug":"team1","description":"Team 1"}]}]`, getTextResult(t, legacyResult).Text) + require.NoError(t, outputSchemas["get_teams"].Validate(result.StructuredContent)) + assert.Equal(t, 2, graphQLCalls) + + result, err = clientSession.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_team_members", + Arguments: map[string]any{"org": "testorg", "team_slug": "testteam", "legacy_ignored_argument": true}, + Meta: protocolMeta, + }) + require.NoError(t, err) + require.False(t, result.IsError) + structuredJSON, err = json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, `["user1","user2"]`, string(structuredJSON)) + assert.JSONEq(t, string(structuredJSON), getTextResult(t, result).Text) + request = createMCPRequest(map[string]any{"org": "testorg", "team_slug": "testteam"}) + legacyResult, err = teamMembersTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.Equal(t, `["user1","user2"]`, getTextResult(t, legacyResult).Text) + require.NoError(t, outputSchemas["get_team_members"].Validate(result.StructuredContent)) + assert.Equal(t, 4, graphQLCalls) + + result, err = clientSession.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_team_members", + Arguments: map[string]any{}, + Meta: protocolMeta, + }) + require.NoError(t, err) + assert.True(t, result.IsError, "missing required arguments should be rejected by the inferred input schema") + assert.Equal(t, 4, graphQLCalls, "schema validation must happen before invoking the handler") + + result, err = clientSession.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_team_members", + Arguments: map[string]any{"org": "", "team_slug": "testteam"}, + Meta: protocolMeta, + }) + require.NoError(t, err) + assert.True(t, result.IsError, "empty required strings should remain invalid") + assert.Equal(t, 4, graphQLCalls, "handler validation must reject empty identifiers before acquiring GraphQL") + + for _, tc := range []struct { + name string + args map[string]any + text string + structured string + matcher githubv4mock.Matcher + }{ + {"get_teams", map[string]any{"user": "no-orgs"}, "null", "[]", emptyOrganizationsMatcher}, + {"get_teams", map[string]any{"user": "no-teams"}, `[{"org":"testorg","teams":[]}]`, `[{"org":"testorg","teams":[]}]`, emptyTeamsMatcher}, + {"get_team_members", map[string]any{"org": "testorg", "team_slug": "emptyteam"}, "null", "[]", emptyMembersMatcher}, + } { + mockedGQLClient = githubv4.NewClient(githubv4mock.NewMockedHTTPClient(tc.matcher)) + serverTool := getTeamsTool + if tc.name == "get_team_members" { + serverTool = teamMembersTool + } + request := createMCPRequest(tc.args) + legacyResult, err := serverTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, legacyResult.IsError) + assert.Equal(t, tc.text, getTextResult(t, legacyResult).Text) + result, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: tc.args, Meta: protocolMeta}) + require.NoError(t, err) + require.False(t, result.IsError) + require.NotNil(t, result.StructuredContent, "empty successful collections must have structured content on the wire") + require.Len(t, result.Content, 1, "SDK fallback must not duplicate the legacy text") + structuredJSON, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, tc.structured, string(structuredJSON)) + assert.JSONEq(t, string(structuredJSON), getTextResult(t, result).Text) + require.NoError(t, outputSchemas[tc.name].Validate(result.StructuredContent)) + } + + failGetMe = true + result, err = clientSession.CallTool(context.Background(), &mcp.CallToolParams{Name: "get_me", Meta: protocolMeta}) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Nil(t, result.StructuredContent, "handler errors must not expose a successful typed output") +} + 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"), + Login: new("testuser"), + HTMLURL: new("https://github.com/testuser"), CreatedAt: &github.Timestamp{Time: time.Now()}, } mockedHTTPClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -240,25 +531,25 @@ func Test_GetTeams(t *testing.T) { serverTool := GetTeams(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testContextToolSnapshot[GetTeamsInput, []OrganizationTeams](t, tool) assert.Equal(t, "get_teams", tool.Name) assert.True(t, tool.Annotations.ReadOnlyHint, "get_teams tool should be read-only") mockUser := &github.User{ - Login: github.Ptr("testuser"), - Name: github.Ptr("Test User"), - Email: github.Ptr("test@example.com"), - Bio: github.Ptr("GitHub user for testing"), - Company: github.Ptr("Test Company"), - Location: github.Ptr("Test Location"), - HTMLURL: github.Ptr("https://github.com/testuser"), + Login: new("testuser"), + Name: new("Test User"), + Email: new("test@example.com"), + Bio: new("GitHub user for testing"), + Company: new("Test Company"), + Location: new("Test Location"), + HTMLURL: new("https://github.com/testuser"), CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)}, - Type: github.Ptr("User"), - Hireable: github.Ptr(true), - TwitterUsername: github.Ptr("testuser_twitter"), + Type: new("User"), + Hireable: new(true), + TwitterUsername: new("testuser_twitter"), Plan: &github.Plan{ - Name: github.Ptr("pro"), + Name: new("pro"), }, } @@ -386,6 +677,17 @@ func Test_GetTeams(t *testing.T) { expectToolError: false, expectedTeamsCount: 2, }, + { + name: "empty user uses authenticated user", + makeDeps: func() ToolDependencies { + return BaseDeps{ + Client: mustNewGHClient(t, httpClientWithUser()), + GQLClient: gqlClientForTestuser(), + } + }, + requestArgs: map[string]any{"user": ""}, + expectedTeamsCount: 2, + }, { name: "no teams found", makeDeps: func() ToolDependencies { @@ -483,7 +785,7 @@ func Test_GetTeamMembers(t *testing.T) { serverTool := GetTeamMembers(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testContextToolSnapshot[GetTeamMembersInput, []string](t, tool) assert.Equal(t, "get_team_members", tool.Name) assert.True(t, tool.Annotations.ReadOnlyHint, "get_team_members tool should be read-only") @@ -612,3 +914,277 @@ func Test_GetTeamMembers(t *testing.T) { }) } } + +func Test_GetTeams_NullUserForDirectHandler(t *testing.T) { + t.Parallel() + + clientCalls := 0 + gqlClientCalls := 0 + deps := stubDeps{ + clientFn: func(context.Context) (*github.Client, error) { + clientCalls++ + return nil, nil + }, + gqlClientFn: func(context.Context) (*githubv4.Client, error) { + gqlClientCalls++ + return nil, nil + }, + obsv: stubExporters(), + } + serverTool := GetTeams(translations.NullTranslationHelper) + request := createMCPRequest(map[string]any{"user": nil}) + result, err := serverTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, "parameter user is not of type string, is ", getErrorResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + assert.Zero(t, clientCalls) + assert.Zero(t, gqlClientCalls) +} + +func TestNormalizeGetTeamsInput(t *testing.T) { + t.Parallel() + + for _, arguments := range []string{`{}`, `{"user":""}`, `{"user":"octocat"}`, `{"legacy_ignored_argument":true}`} { + t.Run(arguments, func(t *testing.T) { + normalized, err := normalizeGetTeamsInput(json.RawMessage(arguments)) + require.NoError(t, err) + assert.Equal(t, arguments, string(normalized)) + }) + } + for _, arguments := range []string{`{"user":null}`, `{"user": null }`} { + t.Run(arguments, func(t *testing.T) { + _, err := normalizeGetTeamsInput(json.RawMessage(arguments)) + var inputError *inventory.ToolInputError + require.ErrorAs(t, err, &inputError) + assert.Equal(t, "parameter user is not of type string, is ", inputError.Message) + }) + } +} + +func TestContextToolsExactRoutingKeys(t *testing.T) { + for _, mode := range []string{"direct", "legacy", "modern", "unknown"} { + t.Run(mode, func(t *testing.T) { + var clientCalls, gqlCalls int + deps := stubDeps{ + clientFn: func(ctx context.Context) (*github.Client, error) { + clientCalls++ + return stubClientFnFromHTTP(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUser: mockResponse(t, http.StatusOK, &github.User{Login: new("authenticated")}), + }))(ctx) + }, + gqlClientFn: func(context.Context) (*githubv4.Client, error) { + gqlCalls++ + return githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}", + map[string]any{"login": "authenticated"}, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{"organizations": map[string]any{"nodes": []any{}}}, + }), + ), + githubv4mock.NewQueryMatcher( + "query($org:String!$teamSlug:String!){organization(login: $org){team(slug: $teamSlug){members(first: 100){nodes{login}}}}}", + map[string]any{"org": "testorg", "teamSlug": "testteam"}, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{"team": map[string]any{"members": map[string]any{"nodes": []any{}}}}, + }), + ), + )), nil + }, + obsv: stubExporters(), + } + tools := []inventory.ServerTool{ + GetTeams(translations.NullTranslationHelper), + GetTeamMembers(translations.NullTranslationHelper), + } + call := func(name string, args map[string]any) *mcp.CallToolResult { + for _, tool := range tools { + if tool.Tool.Name == name { + request := createMCPRequest(args) + if mode == "unknown" { + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: "2099-01-01"} + } + result, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + return result + } + } + t.Fatalf("unknown tool %s", name) + return nil + } + if mode == "legacy" || mode == "modern" { + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + inv.RegisterTools(context.Background(), server, deps) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + version := "2025-03-26" + if mode == "modern" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session, err := client.Connect(context.Background(), clientTransport, &mcp.ClientSessionOptions{ProtocolVersion: version}) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + meta := mcp.Meta{} + if mode == "modern" { + meta[mcp.MetaKeyProtocolVersion] = inventory.ProtocolVersionMultiRoundTrip + } + call = func(name string, args map[string]any) *mcp.CallToolResult { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: name, Arguments: args, Meta: meta, + }) + require.NoError(t, err) + return result + } + } + for _, args := range []map[string]any{ + {"USER": "wrong-user", "unrelated": true}, + {"UsEr": nil}, + {"user": "", "USER": 42}, + {"user": "authenticated", "USER": "wrong-user"}, + } { + beforeREST := clientCalls + beforeGQL := gqlCalls + result := call("get_teams", args) + require.False(t, result.IsError) + assert.Equal(t, beforeGQL+1, gqlCalls) + if args["user"] == "authenticated" { + assert.Equal(t, beforeREST, clientCalls) + } else { + assert.Equal(t, beforeREST+1, clientCalls) + } + if mode == "modern" { + assert.Equal(t, "[]", getTextResult(t, result).Text) + assert.NotNil(t, result.StructuredContent) + } else { + assert.Equal(t, "null", getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } + } + beforeREST, beforeGQL := clientCalls, gqlCalls + result := call("get_teams", map[string]any{"user": nil, "USER": "authenticated"}) + require.True(t, result.IsError) + assert.Equal(t, "parameter user is not of type string, is ", getErrorResult(t, result).Text) + assert.Equal(t, beforeREST, clientCalls) + assert.Equal(t, beforeGQL, gqlCalls) + for _, args := range []map[string]any{ + {"ORG": "testorg", "team_slug": "testteam"}, + {"org": "testorg", "TEAM_SLUG": "testteam"}, + {"ORG": "testorg", "TEAM_SLUG": "testteam"}, + } { + result := call("get_team_members", args) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + assert.Equal(t, beforeGQL, gqlCalls) + } + result = call("get_team_members", map[string]any{ + "org": "testorg", "team_slug": "testteam", "ORG": 42, "TEAM_SLUG": nil, "unrelated": true, + }) + require.False(t, result.IsError) + assert.Equal(t, beforeGQL+1, gqlCalls) + if mode == "modern" { + assert.Equal(t, "[]", getTextResult(t, result).Text) + } else { + assert.Equal(t, "null", getTextResult(t, result).Text) + } + }) + } +} + +func TestNormalizeContextRoutingKeys(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + normalize inventory.InputNormalizer + args string + want string + }{ + {"teams", normalizeGetTeamsInput, `{"user":"exact","USER":42,"UsEr":null,"unknown":{"USER":"retained"}}`, `{"user":"exact","unknown":{"USER":"retained"}}`}, + {"members", normalizeGetTeamMembersInput, `{"org":"exact","ORG":false,"team_slug":"slug","Team_Slug":null,"unknown":true}`, `{"org":"exact","team_slug":"slug","unknown":true}`}, + {"canonical null retained", normalizeGetTeamMembersInput, `{"org":null,"team_slug":null,"ORG":"ignored"}`, `{"org":null,"team_slug":null}`}, + } { + t.Run(tc.name, func(t *testing.T) { + result, err := tc.normalize(json.RawMessage(tc.args)) + require.NoError(t, err) + assert.JSONEq(t, tc.want, string(result)) + }) + } +} + +func Test_GetTeamMembers_RequiredIdentifiersForDirectHandler(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args map[string]any + expectedError string + }{ + { + name: "missing organization", + args: map[string]any{"team_slug": "testteam"}, + expectedError: "missing required parameter: org", + }, + { + name: "empty organization", + args: map[string]any{"org": "", "team_slug": "testteam"}, + expectedError: "missing required parameter: org", + }, + { + name: "missing team slug", + args: map[string]any{"org": "testorg"}, + expectedError: "missing required parameter: team_slug", + }, + { + name: "empty team slug", + args: map[string]any{"org": "testorg", "team_slug": ""}, + expectedError: "missing required parameter: team_slug", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gqlClientCalls := 0 + deps := stubDeps{ + gqlClientFn: func(context.Context) (*githubv4.Client, error) { + gqlClientCalls++ + return nil, nil + }, + obsv: stubExporters(), + } + serverTool := GetTeamMembers(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + request := createMCPRequest(tc.args) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.expectedError) + assert.Zero(t, gqlClientCalls, "invalid identifiers must fail before acquiring the GraphQL client") + }) + } +} + +func testContextToolSnapshot[In, Out any](t *testing.T, tool mcp.Tool) { + t.Helper() + + if tool.InputSchema == nil { + inputSchema, err := jsonschema.For[In](nil) + require.NoError(t, err) + tool.InputSchema = inputSchema + } + if tool.OutputSchema == nil { + outputSchema, err := jsonschema.For[Out](nil) + require.NoError(t, err) + tool.OutputSchema = outputSchema + } + require.NoError(t, toolsnaps.Test(tool.Name, tool)) +} diff --git a/pkg/github/copilot.go b/pkg/github/copilot.go index 1064bef144..151c7e3f59 100644 --- a/pkg/github/copilot.go +++ b/pkg/github/copilot.go @@ -10,15 +10,15 @@ import ( "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" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/octicons" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v89/github" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -39,7 +39,7 @@ func (d *mvpDescription) String() string { 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)) + fmt.Fprintf(&sb, "- %s\n", outcome) } } @@ -47,7 +47,7 @@ func (d *mvpDescription) String() string { 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)) + fmt.Fprintf(&sb, "- %s\n", link) } } @@ -163,12 +163,13 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server }, } - return NewTool( + return NewTool[map[string]any, *AssignCopilotToIssueOutput]( ToolsetMetadataCopilot, mcp.Tool{ - Name: "assign_copilot_to_issue", - Description: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String()), - Icons: octicons.Icons("copilot"), + Name: "assign_copilot_to_issue", + OutputSchema: assignCopilotToIssueOutputSchema(), + 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, @@ -202,7 +203,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, *AssignCopilotToIssueOutput, error) { var params struct { Owner string `mapstructure:"owner"` Repo string `mapstructure:"repo"` @@ -214,6 +215,19 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server return utils.NewToolResultError(err.Error()), nil, nil } + // owner, repo and issue_number are required, but WeakDecode zero-fills a + // missing value, so a missing arg reached the query as a confusing + // "Could not resolve to a Repository" error. Reject the zero values. + if params.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil + } + if params.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil + } + if params.IssueNumber == 0 { + return utils.NewToolResultError("missing required parameter: issue_number"), nil, nil + } + client, err := deps.GetGQLClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) @@ -432,8 +446,26 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server return utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil } - return utils.NewToolResultText(string(r)), result, nil - }) + output := &AssignCopilotToIssueOutput{ + IssueNumber: int(updateIssueMutation.UpdateIssue.Issue.Number), + IssueURL: string(updateIssueMutation.UpdateIssue.Issue.URL), + Message: result["message"].(string), + Owner: params.Owner, + Repo: params.Repo, + } + if note, ok := result["note"].(string); ok { + output.Note = note + } + if linkedPR != nil { + output.PullRequest = &CopilotPullRequestOutput{ + Number: linkedPR.Number, + State: linkedPR.State, + Title: linkedPR.Title, + URL: linkedPR.URL, + } + } + return utils.NewToolResultText(string(r)), output, nil + }, normalizeAssignCopilotToIssueArguments) } // copilotBotAssignee is the minimal shape needed for the copilot-swe-agent bot @@ -512,12 +544,13 @@ func AssignCopilotToIssueWithIntent(t translations.TranslationHelperFunc) invent }, } - return NewTool( + return NewTool[map[string]any, *AssignCopilotToIssueWithIntentOutput]( ToolsetMetadataCopilotIssueIntents, mcp.Tool{ - Name: "assign_copilot_to_issue_with_intent", - Description: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_WITH_INTENT_DESCRIPTION", description.String()), - Icons: octicons.Icons("copilot"), + Name: "assign_copilot_to_issue_with_intent", + OutputSchema: assignCopilotToIssueWithIntentOutputSchema(), + Description: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_WITH_INTENT_DESCRIPTION", description.String()), + Icons: octicons.Icons("copilot"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_WITH_INTENT_USER_TITLE", "Assign Copilot to issue with intent"), ReadOnlyHint: false, @@ -550,7 +583,7 @@ func AssignCopilotToIssueWithIntent(t translations.TranslationHelperFunc) invent Type: "string", Description: "One concise sentence explaining what specifically about the issue led to choosing Copilot. " + "State the concrete signal (e.g. 'Well-scoped task with clear acceptance criteria').", - MaxLength: jsonschema.Ptr(280), + MaxLength: new(280), }, "confidence": { Type: "string", @@ -566,13 +599,19 @@ func AssignCopilotToIssueWithIntent(t translations.TranslationHelperFunc) invent }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, *AssignCopilotToIssueWithIntentOutput, error) { // Presence-check is_suggestion before decoding: mapstructure defaults a // missing bool to false, which would silently launch Copilot instead of // recording a suggestion. Require callers to make the choice explicit. if _, ok := args["is_suggestion"]; !ok { return utils.NewToolResultError("is_suggestion is required"), nil, nil } + if rationale, ok := args["_compat_rationale"]; ok { + args["rationale"] = rationale + } + if confidence, ok := args["_compat_confidence"]; ok { + args["confidence"] = confidence + } var params struct { Owner string `mapstructure:"owner"` @@ -588,6 +627,19 @@ func AssignCopilotToIssueWithIntent(t translations.TranslationHelperFunc) invent return utils.NewToolResultError(err.Error()), nil, nil } + // owner, repo and issue_number are required, but WeakDecode zero-fills a + // missing value, so reject the zero values (as with rationale/confidence + // below) before they reach the query as a confusing repository error. + if params.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil + } + if params.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil + } + if params.IssueNumber == 0 { + return utils.NewToolResultError("missing required parameter: issue_number"), nil, nil + } + // Validate rationale length (rune count, matching the granular assignee tools). rationale := strings.TrimSpace(params.Rationale) if rationale == "" { @@ -729,7 +781,14 @@ func AssignCopilotToIssueWithIntent(t translations.TranslationHelperFunc) invent if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil } - return utils.NewToolResultText(string(r)), result, nil + return utils.NewToolResultText(string(r)), &AssignCopilotToIssueWithIntentOutput{ + IssueNumber: int(updateIssueMutation.UpdateIssue.Issue.Number), + IssueURL: string(updateIssueMutation.UpdateIssue.Issue.URL), + IsSuggestion: params.IsSuggestion, + Message: result["message"].(string), + Owner: params.Owner, + Repo: params.Repo, + }, nil } // Direct-assignment path: poll for a linked PR created by Copilot after the assignment. @@ -784,8 +843,27 @@ func AssignCopilotToIssueWithIntent(t translations.TranslationHelperFunc) invent if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil } - return utils.NewToolResultText(string(r)), result, nil - }) + output := &AssignCopilotToIssueWithIntentOutput{ + IssueNumber: int(updateIssueMutation.UpdateIssue.Issue.Number), + IssueURL: string(updateIssueMutation.UpdateIssue.Issue.URL), + IsSuggestion: params.IsSuggestion, + Message: result["message"].(string), + Owner: params.Owner, + Repo: params.Repo, + } + if note, ok := result["note"].(string); ok { + output.Note = note + } + if linkedPR != nil { + output.PullRequest = &CopilotPullRequestOutput{ + Number: linkedPR.Number, + State: linkedPR.State, + Title: linkedPR.Title, + URL: linkedPR.URL, + } + } + return utils.NewToolResultText(string(r)), output, nil + }, normalizeAssignCopilotToIssueWithIntentArguments) } type ReplaceActorsForAssignableInput struct { @@ -855,12 +933,13 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server Required: []string{"owner", "repo", "pullNumber"}, } - return NewTool( + return NewTool[map[string]any, CopilotReviewOutput]( 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"), + Name: "request_copilot_review", + OutputSchema: copilotReviewOutputSchema(), + 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, @@ -868,25 +947,25 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server InputSchema: schema, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, CopilotReviewOutput, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), CopilotReviewOutput{}, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), CopilotReviewOutput{}, nil } pullNumber, err := RequiredInt(args, "pullNumber") if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), CopilotReviewOutput{}, nil } client, err := deps.GetClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), CopilotReviewOutput{}, nil } _, resp, err := client.PullRequests.RequestReviewers( @@ -904,21 +983,21 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server copilotReviewErrMsg(ctx, client, "failed to request copilot review", owner, repo, pullNumber, resp, err), resp, err, - ), nil, nil + ), CopilotReviewOutput{}, 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 utils.NewToolResultErrorFromErr("failed to read response body", err), CopilotReviewOutput{}, nil } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to request copilot review", resp, bodyBytes), nil, nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to request copilot review", resp, bodyBytes), CopilotReviewOutput{}, nil } // Return nothing on success, as there's not much value in returning the Pull Request itself - return utils.NewToolResultText(""), nil, nil - }) + return utils.NewToolResultText(""), CopilotReviewOutput{Status: "requested"}, nil + }, normalizeRequestCopilotReviewArguments) } // copilotReviewErrMsg disambiguates the bare 404 this endpoint returns when the diff --git a/pkg/github/copilot_test.go b/pkg/github/copilot_test.go index c90de4804b..7d7ecf9ac7 100644 --- a/pkg/github/copilot_test.go +++ b/pkg/github/copilot_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -59,6 +59,36 @@ func TestAssignCopilotToIssue(t *testing.T) { expectToolError bool expectedToolErrMsg string }{ + { + name: "missing owner is rejected", + requestArgs: map[string]any{ + "repo": "repo", + "issue_number": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "missing required parameter: owner", + }, + { + name: "missing repo is rejected", + requestArgs: map[string]any{ + "owner": "owner", + "issue_number": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "missing required parameter: repo", + }, + { + name: "missing issue_number is rejected", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "missing required parameter: issue_number", + }, { name: "successful assignment when there are no existing assignees", requestArgs: map[string]any{ @@ -870,20 +900,20 @@ func Test_RequestCopilotReview(t *testing.T) { // 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"), + Number: new(42), + Title: new("Test PR"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), Head: &github.PullRequestBranch{ - SHA: github.Ptr("abcd1234"), - Ref: github.Ptr("feature-branch"), + SHA: new("abcd1234"), + Ref: new("feature-branch"), }, Base: &github.PullRequestBranch{ - Ref: github.Ptr("main"), + Ref: new("main"), }, - Body: github.Ptr("This is a test PR"), + Body: new("This is a test PR"), User: &github.User{ - Login: github.Ptr("testuser"), + Login: new("testuser"), }, } @@ -935,10 +965,10 @@ func Test_RequestCopilotReview(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusNotFound, map[string]any{"message": "Not Found"}), GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{ - Name: github.Ptr("repo"), + Name: new("repo"), Permissions: &github.RepositoryPermissions{ - Pull: github.Ptr(true), - Push: github.Ptr(false), + Pull: new(true), + Push: new(false), }, }), }), @@ -955,10 +985,10 @@ func Test_RequestCopilotReview(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusForbidden, map[string]any{"message": "Forbidden"}), GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{ - Name: github.Ptr("repo"), + Name: new("repo"), Permissions: &github.RepositoryPermissions{ - Pull: github.Ptr(true), - Push: github.Ptr(false), + Pull: new(true), + Push: new(false), }, }), }), @@ -975,10 +1005,10 @@ func Test_RequestCopilotReview(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusNotFound, map[string]any{"message": "Not Found"}), GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{ - Name: github.Ptr("repo"), + Name: new("repo"), Permissions: &github.RepositoryPermissions{ - Pull: github.Ptr(true), - Push: github.Ptr(true), + Pull: new(true), + Push: new(true), }, }), }), @@ -1228,6 +1258,19 @@ func TestAssignCopilotToIssueWithIntent(t *testing.T) { expectedToolErrMsg string expectSuggestion bool }{ + { + name: "missing owner is rejected", + requestArgs: map[string]any{ + "repo": "repo", + "issue_number": float64(123), + "rationale": "Well-scoped task.", + "confidence": "HIGH", + "is_suggestion": false, + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "missing required parameter: owner", + }, { name: "direct assignment with rationale and confidence preserves existing assignees", requestArgs: map[string]any{ diff --git a/pkg/github/csv_output.go b/pkg/github/csv_output.go index 6acb8b2fdb..ee4bba19db 100644 --- a/pkg/github/csv_output.go +++ b/pkg/github/csv_output.go @@ -9,8 +9,8 @@ import ( "sort" "strings" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -42,16 +42,14 @@ type csvOutputDocument struct { 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. +// withCSVOutput adds dependency-aware middleware to every default-toolset +// list_* tool so that it converts JSON text to CSV when the feature is enabled. func withCSVOutput(tools []inventory.ServerTool) []inventory.ServerTool { for i := range tools { if !isCSVOutputTool(tools[i]) { continue } - tools[i].HandlerFunc = wrapHandlerWithCSVOutput(tools[i].HandlerFunc) + tools[i].AddHandlerMiddleware(csvOutputMiddleware) } return tools } @@ -68,18 +66,18 @@ func isCSVOutputTool(tool inventory.ServerTool) bool { 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) +func csvOutputMiddleware(deps any) inventory.ToolHandlerMiddleware { + csvDeps, _ := deps.(ToolDependencies) + return func(next mcp.ToolHandler) mcp.ToolHandler { return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - result, err := handler(ctx, req) + result, err := next(ctx, req) if err != nil || result == nil || result.IsError { return result, err } if csvDeps == nil || !csvDeps.IsFeatureEnabled(ctx, FeatureFlagCSVOutput) { - return result, nil + return result, err } + inventory.PreserveToolHandlerContent(ctx) return convertJSONTextResultToCSV(result), nil } } diff --git a/pkg/github/csv_output_test.go b/pkg/github/csv_output_test.go index 5cc6fe7e54..43e59ec0e9 100644 --- a/pkg/github/csv_output_test.go +++ b/pkg/github/csv_output_test.go @@ -4,10 +4,11 @@ import ( "context" "encoding/csv" "encoding/json" + "errors" "strings" "testing" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" @@ -29,20 +30,18 @@ func TestCSVOutputAppliedToDefaultListTools(t *testing.T) { require.Len(t, available, 2) listing := requireToolByName(t, available, "list_things") - assert.Empty(t, listing.FeatureFlagEnable) - assert.Empty(t, listing.FeatureFlagDisable) + assert.True(t, listing.FeatureRule.IsZero()) getting := requireToolByName(t, available, "get_thing") - assert.Empty(t, getting.FeatureFlagEnable) - assert.Empty(t, getting.FeatureFlagDisable) + assert.True(t, getting.FeatureRule.IsZero()) } } func TestCSVOutputAppliesToFlagGatedListTools(t *testing.T) { enabledOnly := testCSVOutputTool("list_things", `[{"number":1}]`) - enabledOnly.FeatureFlagEnable = FeatureFlagFileBlame + enabledOnly.FeatureRule = featureEnabledRule(FeatureFlagFileBlame) disabledOnly := testCSVOutputTool("list_legacy_things", `[{"number":2}]`) - disabledOnly.FeatureFlagDisable = []string{FeatureFlagFileBlame} + disabledOnly.FeatureRule = featureDisabledRule(FeatureFlagFileBlame) tools := withCSVOutput([]inventory.ServerTool{enabledOnly, disabledOnly}) require.Len(t, tools, 2) @@ -128,6 +127,114 @@ func TestCSVOutputPreservesOriginalJSONWhenFlagOff(t *testing.T) { assert.JSONEq(t, jsonResponse, text.Text) } +func TestCSVOutputMiddlewareAppliesToTypedToolRegistration(t *testing.T) { + type row struct { + Number int `json:"number"` + } + tools := withCSVOutput([]inventory.ServerTool{ + inventory.NewServerToolWithContextHandler( + mcp.Tool{Name: "list_typed_things"}, + ToolsetMetadataRepos, + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, []row, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: `[{"number":1}]`}}, + }, []row{{Number: 1}}, nil + }, + ), + }) + deps := newCSVOutputTestDeps(true) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tools[0].RegisterFunc(server, deps) + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + clientSession, err := client.Connect(context.Background(), clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = clientSession.Close() }) + + result, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{Name: "list_typed_things"}) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, `[{"number":1}]`, mustMarshalCSVJSON(t, result.StructuredContent)) + assert.Contains(t, textResult(t, result), "number\n1") +} + +func TestCSVOutputTypedArrayPreservesLegacyProtocolResult(t *testing.T) { + type row struct { + Number int `json:"number"` + } + type object struct { + Number int `json:"number"` + } + tools := withCSVOutput([]inventory.ServerTool{ + inventory.NewServerToolWithContextHandler( + mcp.Tool{Name: "list_typed_things"}, + ToolsetMetadataRepos, + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, []row, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: `[{"number":1}]`}}, + }, []row{{Number: 1}}, nil + }, + ), + inventory.NewServerToolWithContextHandler( + mcp.Tool{Name: "get_typed_thing"}, + ToolsetMetadataRepos, + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, object, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: `{"number":1}`}}, + }, object{Number: 1}, nil + }, + ), + }) + deps := newCSVOutputTestDeps(true) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + if method == "server/discover" { + return nil, errors.New("legacy client uses initialize") + } + return next(ctx, method, request) + } + }) + for _, tool := range tools { + tool.RegisterFunc(server, deps) + } + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + clientSession, err := client.Connect(context.Background(), clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = clientSession.Close() }) + require.Equal(t, "2025-11-25", clientSession.InitializeResult().ProtocolVersion) + + list, err := clientSession.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 2) + for _, tool := range list.Tools { + assert.Nil(t, tool.OutputSchema, "legacy tools/list must hide typed output schemas") + } + + listResult, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{Name: "list_typed_things"}) + require.NoError(t, err) + require.False(t, listResult.IsError) + assert.Nil(t, listResult.StructuredContent) + require.Len(t, listResult.Content, 1, "CSV text must not be duplicated by the SDK's JSON array fallback") + assert.Equal(t, "number\n1\n", textResult(t, listResult)) + + objectResult, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{Name: "get_typed_thing"}) + require.NoError(t, err) + require.False(t, objectResult.IsError) + assert.Nil(t, objectResult.StructuredContent) + require.Len(t, objectResult.Content, 1) + assert.Equal(t, `{"number":1}`, textResult(t, objectResult)) +} + func TestCSVOutputVariantMovesMetadataToPreamble(t *testing.T) { csvText, err := jsonTextToCSV(`{ "issues": [ @@ -154,6 +261,13 @@ func TestCSVOutputVariantMovesMetadataToPreamble(t *testing.T) { assert.NotContains(t, row, "totalCount") } +func mustMarshalCSVJSON(t *testing.T, value any) string { + t.Helper() + data, err := json.Marshal(value) + require.NoError(t, err) + return string(data) +} + func TestJSONTextToCSVFlattensPrimaryRows(t *testing.T) { csvText, err := jsonTextToCSV(`{ "discussions": [ diff --git a/pkg/github/custom_properties.go b/pkg/github/custom_properties.go new file mode 100644 index 0000000000..055efc4461 --- /dev/null +++ b/pkg/github/custom_properties.go @@ -0,0 +1,500 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const customPropertiesLevelDescription = "The level at which custom properties are managed:\n" + + "- 'repository': The custom property VALUES assigned to a repository (requires 'owner' and 'repo').\n" + + "- 'organization': The custom property DEFINITIONS (schema) for an organization (requires 'org').\n" + + "- 'enterprise': The custom property DEFINITIONS (schema) for an enterprise (requires 'enterprise')." + +// CustomPropertiesRead creates the custom properties read tool. +func CustomPropertiesRead(t translations.TranslationHelperFunc) inventory.ServerTool { + return typedGovernanceTool(customPropertiesReadLegacy(t), customPropertiesReadOutputSchema(), decodeCustomProperties("read"), normalizeGovernanceLevelRouting) +} + +func customPropertiesReadLegacy(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "custom_properties_read", + Description: t("TOOL_CUSTOM_PROPERTIES_READ_DESCRIPTION", "Read custom properties at the repository, organization, or enterprise level. At the repository level this returns the property values assigned to a repository; at the organization and enterprise levels it returns the property definitions (schema). Select the level with the 'level' parameter."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CUSTOM_PROPERTIES_READ_USER_TITLE", "Read custom properties"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "level": { + Type: "string", + Enum: []any{"repository", "organization", "enterprise"}, + Description: customPropertiesLevelDescription, + }, + "owner": { + Type: "string", + Description: "Repository owner. Required when level is 'repository'.", + }, + "repo": { + Type: "string", + Description: "Repository name. Required when level is 'repository'.", + }, + "org": { + Type: "string", + Description: "Organization name. Required when level is 'organization'.", + }, + "enterprise": { + Type: "string", + Description: "Enterprise slug. Required when level is 'enterprise'.", + }, + }, + Required: []string{"level"}, + }, + }, + rulesetReadScopeAccess(), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + level, err := RequiredParam[string](args, "level") + 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) + } + + switch level { + case "repository": + return customPropertiesReadRepository(ctx, client, args) + case "organization": + return customPropertiesReadOrganization(ctx, client, args) + case "enterprise": + return customPropertiesReadEnterprise(ctx, client, args) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown level: %q (expected 'repository', 'organization', or 'enterprise')", level)), nil, nil + } + }, + ) +} + +func customPropertiesReadRepository(ctx context.Context, client *github.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 + } + + properties, resp, err := client.Repositories.GetAllCustomPropertyValues(ctx, owner, repo) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get repository custom property values", resp, err), nil, nil + } + + return MarshalledTextResult(properties), nil, nil +} + +func customPropertiesReadOrganization(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + properties, errResult := getCustomPropertyDefinitions(ctx, client, fmt.Sprintf("orgs/%s/properties/schema", url.PathEscape(org)), "failed to get organization custom properties") + if errResult != nil { + return errResult, nil, nil + } + + return MarshalledTextResult(properties), nil, nil +} + +func customPropertiesReadEnterprise(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + enterprise, err := RequiredParam[string](args, "enterprise") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + properties, errResult := getCustomPropertyDefinitions(ctx, client, fmt.Sprintf("enterprises/%s/properties/schema", url.PathEscape(enterprise)), "failed to get enterprise custom properties") + if errResult != nil { + return errResult, nil, nil + } + + return MarshalledTextResult(properties), nil, nil +} + +// CustomPropertiesWrite creates the custom properties write tool. +func CustomPropertiesWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + return typedGovernanceTool(customPropertiesWriteLegacy(t), customPropertiesWriteOutputSchema(), decodeCustomProperties("write"), normalizeGovernanceLevelRouting) +} + +func customPropertiesWriteLegacy(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "custom_properties_write", + Description: t("TOOL_CUSTOM_PROPERTIES_WRITE_DESCRIPTION", "Create or update custom properties at the repository, organization, or enterprise level. At the repository level this sets the property values on a repository (the properties must already be defined for the organization). Organization and enterprise definition writes preserve omitted writable fields by reading current definitions immediately before updating; concurrent definition updates remain last-write-wins. Select the level with the 'level' parameter."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CUSTOM_PROPERTIES_WRITE_USER_TITLE", "Set custom properties"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "level": { + Type: "string", + Enum: []any{"repository", "organization", "enterprise"}, + Description: customPropertiesLevelDescription, + }, + "owner": { + Type: "string", + Description: "Repository owner. Required when level is 'repository'.", + }, + "repo": { + Type: "string", + Description: "Repository name. Required when level is 'repository'.", + }, + "org": { + Type: "string", + Description: "Organization name. Required when level is 'organization'.", + }, + "enterprise": { + Type: "string", + Description: "Enterprise slug. Required when level is 'enterprise'.", + }, + "properties": { + Type: "array", + Description: "The custom properties to create or update. At the repository level each item assigns a value ('property_name' and 'value'); at the organization and enterprise levels each item defines the schema ('property_name' and 'value_type', plus optional definition fields).", + Items: &jsonschema.Schema{ + Type: "object", + OneOf: []*jsonschema.Schema{ + customPropertyValueSchema(), + customPropertyDefinitionSchema(), + }, + }, + }, + }, + Required: []string{"level", "properties"}, + }, + }, + rulesetWriteScopeAccess(), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + level, err := RequiredParam[string](args, "level") + 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) + } + + switch level { + case "repository": + return customPropertiesWriteRepository(ctx, client, args) + case "organization": + return customPropertiesWriteOrganization(ctx, client, args) + case "enterprise": + return customPropertiesWriteEnterprise(ctx, client, args) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown level: %q (expected 'repository', 'organization', or 'enterprise')", level)), nil, nil + } + }, + ) +} + +func customPropertiesWriteRepository(ctx context.Context, client *github.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 + } + values, errResult := parseCustomProperties[*github.CustomPropertyValue](args, customPropertyValueSchema()) + if errResult != nil { + return errResult, nil, nil + } + + resp, err := client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repo, values) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update repository custom property values", resp, err), nil, nil + } + + return utils.NewToolResultText("Repository custom property values updated successfully"), nil, nil +} + +func customPropertiesWriteOrganization(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + properties, errResult := parseCustomProperties[map[string]any](args, customPropertyDefinitionSchema()) + if errResult != nil { + return errResult, nil, nil + } + + return customPropertiesWriteDefinitions(ctx, client, fmt.Sprintf("orgs/%s/properties/schema", url.PathEscape(org)), "organization", properties) +} + +func customPropertiesWriteEnterprise(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + enterprise, err := RequiredParam[string](args, "enterprise") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + properties, errResult := parseCustomProperties[map[string]any](args, customPropertyDefinitionSchema()) + if errResult != nil { + return errResult, nil, nil + } + + return customPropertiesWriteDefinitions(ctx, client, fmt.Sprintf("enterprises/%s/properties/schema", url.PathEscape(enterprise)), "enterprise", properties) +} + +func customPropertyValueSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + Description: "A repository-level custom property value.", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "property_name": { + Type: "string", + Description: "The name of the custom property.", + }, + "value": { + Description: "Repository level only: the value to assign. A string, an array of strings, or null to clear the value.", + OneOf: []*jsonschema.Schema{ + {Type: "string"}, + { + Type: "array", + Items: &jsonschema.Schema{Type: "string"}, + }, + {Type: "null"}, + }, + }, + }, + Required: []string{"property_name", "value"}, + } +} + +func customPropertyDefinitionSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + Description: "An organization- or enterprise-level custom property definition.", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "property_name": { + Type: "string", + Description: "The name of the custom property.", + }, + "value_type": { + Type: "string", + Enum: []any{"string", "single_select", "multi_select", "true_false", "url"}, + Description: "The data type of the property. Required for new definitions; omit when updating to preserve the current type.", + }, + "required": { + Type: "boolean", + Description: "Whether the property must be set on every repository. Omit when updating to preserve the current setting.", + }, + "default_value": { + Description: "The value applied when a repository does not set the property. Omit when updating to preserve the current value; use null to clear it.", + OneOf: []*jsonschema.Schema{ + {Type: "string"}, + { + Type: "array", + Items: &jsonschema.Schema{Type: "string"}, + }, + {Type: "null"}, + }, + }, + "description": { + Description: "A short description of the property. Omit when updating to preserve the current description; use null to clear it.", + AnyOf: []*jsonschema.Schema{ + {Type: "string"}, + {Type: "null"}, + }, + }, + "allowed_values": { + Description: "The ordered list of allowed values for single_select and multi_select properties. Omit when updating to preserve the current list; use null or an empty array to clear it.", + AnyOf: []*jsonschema.Schema{ + { + Type: "array", + Items: &jsonschema.Schema{Type: "string"}, + }, + {Type: "null"}, + }, + }, + "values_editable_by": { + Description: "Who can edit the values of the property. Omit when updating to preserve the current setting; use null to restore the default.", + AnyOf: []*jsonschema.Schema{ + { + Type: "string", + Enum: []any{"org_actors", "org_and_repo_actors"}, + }, + {Type: "null"}, + }, + }, + "require_explicit_values": { + Type: "boolean", + Description: "Whether repositories must explicitly set a value for the property. Omit when updating to preserve the current setting.", + }, + }, + Required: []string{"property_name"}, + } +} + +var customPropertyDefinitionFields = []string{ + "value_type", + "required", + "default_value", + "description", + "allowed_values", + "values_editable_by", + "require_explicit_values", +} + +func customPropertiesWriteDefinitions(ctx context.Context, client *github.Client, apiURL, sourceType string, requested []map[string]any) (*mcp.CallToolResult, any, error) { + if errResult := validateUniqueCustomPropertyNames(requested); errResult != nil { + return errResult, nil, nil + } + + current, errResult := getCustomPropertyDefinitions(ctx, client, apiURL, fmt.Sprintf("failed to get %s custom properties before updating", sourceType)) + if errResult != nil { + return errResult, nil, nil + } + merged, errResult := mergeCustomPropertyDefinitions(current, requested, sourceType) + if errResult != nil { + return errResult, nil, nil + } + + req, err := client.NewRequest(ctx, http.MethodPatch, apiURL, map[string]any{"properties": merged}) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create custom properties update request", err), nil, nil + } + + var updated []map[string]any + resp, err := client.Do(req, &updated) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to update %s custom properties", sourceType), resp, err), nil, nil + } + return MarshalledTextResult(updated), nil, nil +} + +func getCustomPropertyDefinitions(ctx context.Context, client *github.Client, apiURL, errorMessage string) ([]map[string]any, *mcp.CallToolResult) { + req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to create custom properties request", err) + } + + var properties []map[string]any + resp, err := client.Do(req, &properties) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return nil, ghErrors.NewGitHubAPIErrorResponse(ctx, errorMessage, resp, err) + } + return properties, nil +} + +func validateUniqueCustomPropertyNames(properties []map[string]any) *mcp.CallToolResult { + seen := make(map[string]struct{}, len(properties)) + for i, property := range properties { + name := property["property_name"].(string) + if _, ok := seen[name]; ok { + return utils.NewToolResultError(fmt.Sprintf("properties[%d].property_name duplicates %q", i, name)) + } + seen[name] = struct{}{} + } + return nil +} + +func mergeCustomPropertyDefinitions(current, requested []map[string]any, sourceType string) ([]map[string]any, *mcp.CallToolResult) { + currentByName := make(map[string]map[string]any, len(current)) + for _, property := range current { + name, _ := property["property_name"].(string) + if name != "" { + currentByName[name] = property + } + } + + merged := make([]map[string]any, 0, len(requested)) + for i, update := range requested { + name := update["property_name"].(string) + existing, exists := currentByName[name] + if !exists { + if _, ok := update["value_type"]; !ok { + return nil, utils.NewToolResultError(fmt.Sprintf("properties[%d].value_type is required for new property %q", i, name)) + } + merged = append(merged, update) + continue + } + if existingSource, _ := existing["source_type"].(string); existingSource != "" && existingSource != sourceType { + return nil, utils.NewToolResultError(fmt.Sprintf("property %q is inherited from %s and cannot be updated at the %s level", name, existingSource, sourceType)) + } + + property := map[string]any{"property_name": name} + for _, field := range customPropertyDefinitionFields { + if value, ok := existing[field]; ok { + property[field] = value + } + if value, ok := update[field]; ok { + property[field] = value + } + } + merged = append(merged, property) + } + return merged, nil +} + +func parseCustomProperties[T any](args map[string]any, itemSchema *jsonschema.Schema) ([]T, *mcp.CallToolResult) { + raw, ok := args["properties"] + if !ok || raw == nil { + return nil, utils.NewToolResultError("properties parameter is required") + } + arr, ok := raw.([]any) + if !ok { + return nil, utils.NewToolResultError("properties parameter must be an array") + } + resolved, err := itemSchema.Resolve(nil) + if err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to resolve properties schema", err) + } + for i, property := range arr { + if err := resolved.Validate(property); err != nil { + return nil, utils.NewToolResultErrorFromErr(fmt.Sprintf("properties[%d] is invalid", i), err) + } + } + + encoded, err := json.Marshal(arr) + if err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to encode properties", err) + } + var out []T + if err := json.Unmarshal(encoded, &out); err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to parse properties", err) + } + return out, nil +} diff --git a/pkg/github/custom_properties_test.go b/pkg/github/custom_properties_test.go new file mode 100644 index 0000000000..a2ff2d69c1 --- /dev/null +++ b/pkg/github/custom_properties_test.go @@ -0,0 +1,554 @@ +package github + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" +) + +func Test_CustomPropertiesRead(t *testing.T) { + toolDef := CustomPropertiesRead(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "custom_properties_read", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint) + + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.ElementsMatch(t, []string{"level"}, schema.Required) + assert.Equal(t, "string", schema.Properties["level"].Type) + + t.Run("repository level: returns property values", func(t *testing.T) { + mockValues := []*github.CustomPropertyValue{{PropertyName: "environment", Value: "production"}} + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/properties/values": mockResponse(t, http.StatusOK, mockValues), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "owner": "owner", "repo": "repo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned []*github.CustomPropertyValue + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + require.Len(t, returned, 1) + assert.Equal(t, "environment", returned[0].PropertyName) + }) + + t.Run("repository level: requires owner and repo", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "owner": "owner"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "repo") + }) + + t.Run("organization level: returns property definitions", func(t *testing.T) { + mockProps := []map[string]any{{ + "property_name": "environment", + "value_type": "single_select", + "require_explicit_values": true, + }} + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/properties/schema": mockResponse(t, http.StatusOK, mockProps), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization", "org": "octo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned []map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + require.Len(t, returned, 1) + assert.Equal(t, "environment", returned[0]["property_name"]) + assert.Equal(t, true, returned[0]["require_explicit_values"]) + }) + + t.Run("organization level: requires org", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "org") + }) + + t.Run("enterprise level: returns property definitions", func(t *testing.T) { + mockProps := []*github.CustomProperty{{PropertyName: new("compliance"), ValueType: "true_false"}} + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /enterprises/{enterprise}/properties/schema": mockResponse(t, http.StatusOK, mockProps), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "enterprise", "enterprise": "acme"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned []*github.CustomProperty + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + require.Len(t, returned, 1) + assert.Equal(t, "compliance", returned[0].GetPropertyName()) + }) + + t.Run("unknown level returns an error", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "team"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "unknown level") + }) +} + +func Test_CustomPropertiesWrite(t *testing.T) { + toolDef := CustomPropertiesWrite(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "custom_properties_write", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + assert.False(t, toolDef.Tool.Annotations.ReadOnlyHint) + + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.ElementsMatch(t, []string{"level", "properties"}, schema.Required) + assert.Equal(t, "string", schema.Properties["level"].Type) + assert.Equal(t, "array", schema.Properties["properties"].Type) + + t.Run("property items use level-specific schemas", func(t *testing.T) { + itemSchema := schema.Properties["properties"].Items + require.NotNil(t, itemSchema) + assert.Equal(t, "object", itemSchema.Type) + require.Len(t, itemSchema.OneOf, 2) + valueItemSchema := itemSchema.OneOf[0] + definitionItemSchema := itemSchema.OneOf[1] + require.NotNil(t, valueItemSchema.AdditionalProperties.Not) + require.NotNil(t, definitionItemSchema.AdditionalProperties.Not) + assert.ElementsMatch(t, []string{"property_name", "value"}, valueItemSchema.Required) + assert.ElementsMatch(t, []string{"property_name"}, definitionItemSchema.Required) + assert.Equal(t, "boolean", definitionItemSchema.Properties["require_explicit_values"].Type) + + resolvedValue, err := valueItemSchema.Resolve(nil) + require.NoError(t, err) + resolvedDefinition, err := definitionItemSchema.Resolve(nil) + require.NoError(t, err) + + valueSchema := valueItemSchema.Properties["value"] + require.Len(t, valueSchema.OneOf, 3) + assert.Equal(t, "string", valueSchema.OneOf[0].Type) + assert.Equal(t, "array", valueSchema.OneOf[1].Type) + assert.Equal(t, "string", valueSchema.OneOf[1].Items.Type) + assert.Equal(t, "null", valueSchema.OneOf[2].Type) + + defaultValueSchema := definitionItemSchema.Properties["default_value"] + require.Len(t, defaultValueSchema.OneOf, 3) + assert.Equal(t, "string", defaultValueSchema.OneOf[0].Type) + assert.Equal(t, "array", defaultValueSchema.OneOf[1].Type) + assert.Equal(t, "string", defaultValueSchema.OneOf[1].Items.Type) + assert.Equal(t, "null", defaultValueSchema.OneOf[2].Type) + + tests := []struct { + name string + definition bool + property map[string]any + shouldPass bool + }{ + {name: "repository string", property: map[string]any{"property_name": "environment", "value": "production"}, shouldPass: true}, + {name: "repository string array", property: map[string]any{"property_name": "environment", "value": []any{"production", "staging"}}, shouldPass: true}, + {name: "repository null", property: map[string]any{"property_name": "environment", "value": nil}, shouldPass: true}, + {name: "repository number", property: map[string]any{"property_name": "environment", "value": 1}}, + {name: "repository boolean", property: map[string]any{"property_name": "environment", "value": true}}, + {name: "repository object", property: map[string]any{"property_name": "environment", "value": map[string]any{"name": "production"}}}, + {name: "repository definition field", property: map[string]any{"property_name": "environment", "value": "production", "required": true}}, + { + name: "definition all fields", + definition: true, + property: map[string]any{ + "property_name": "environment", + "value_type": "single_select", + "required": true, + "default_value": "production", + "description": "Deployment environment", + "allowed_values": []any{"production", "staging"}, + "values_editable_by": "org_and_repo_actors", + "require_explicit_values": true, + }, + shouldPass: true, + }, + {name: "partial definition", definition: true, property: map[string]any{"property_name": "environment", "required": false}, shouldPass: true}, + {name: "definition array default", definition: true, property: map[string]any{"property_name": "compliance", "value_type": "multi_select", "default_value": []any{"soc2", "fedramp"}}, shouldPass: true}, + {name: "definition explicit nulls", definition: true, property: map[string]any{"property_name": "environment", "default_value": nil, "description": nil, "allowed_values": nil, "values_editable_by": nil}, shouldPass: true}, + {name: "definition number default", definition: true, property: map[string]any{"property_name": "environment", "value_type": "string", "default_value": 1}}, + {name: "definition boolean default", definition: true, property: map[string]any{"property_name": "environment", "value_type": "string", "default_value": true}}, + {name: "definition object default", definition: true, property: map[string]any{"property_name": "environment", "value_type": "string", "default_value": map[string]any{"name": "production"}}}, + {name: "definition repository field", definition: true, property: map[string]any{"property_name": "environment", "value_type": "string", "value": "production"}}, + {name: "definition misspelled field", definition: true, property: map[string]any{"property_name": "environment", "value_type": "string", "require": true}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolved := resolvedValue + if tt.definition { + resolved = resolvedDefinition + } + err := resolved.Validate(tt.property) + if tt.shouldPass { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } + }) + + t.Run("repository level: sets property values with one request", func(t *testing.T) { + requests := 0 + var captured struct { + Properties []*github.CustomPropertyValue `json:"properties"` + } + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "PATCH /repos/{owner}/{repo}/properties/values": func(w http.ResponseWriter, r *http.Request) { + requests++ + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &captured) + w.WriteHeader(http.StatusNoContent) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "properties": []any{ + map[string]any{"property_name": "environment", "value": "production"}, + map[string]any{"property_name": "deprecated", "value": nil}, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "updated successfully") + assert.Equal(t, 1, requests) + + require.Len(t, captured.Properties, 2) + assert.Equal(t, "environment", captured.Properties[0].PropertyName) + assert.Equal(t, "production", captured.Properties[0].Value) + assert.Equal(t, "deprecated", captured.Properties[1].PropertyName) + assert.Nil(t, captured.Properties[1].Value) + }) + + t.Run("repository level: requires properties", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "owner": "owner", "repo": "repo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "properties parameter is required") + }) + + t.Run("organization level: merges mixed create and update in two requests", func(t *testing.T) { + requests := 0 + var captured struct { + Properties []map[string]any `json:"properties"` + } + current := []map[string]any{ + { + "property_name": "environment", + "source_type": "organization", + "url": "https://api.github.com/orgs/octo/properties/schema/environment", + "value_type": "single_select", + "required": true, + "default_value": "production", + "description": "Deployment environment", + "allowed_values": []string{"production", "staging"}, + "values_editable_by": "org_and_repo_actors", + "require_explicit_values": true, + }, + { + "property_name": "legacy", + "source_type": "organization", + "value_type": "string", + "required": false, + "default_value": nil, + "description": "", + "allowed_values": []string{}, + "values_editable_by": nil, + "require_explicit_values": false, + }, + { + "property_name": "untouched", + "source_type": "organization", + "value_type": "string", + }, + } + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/properties/schema": func(w http.ResponseWriter, r *http.Request) { + requests++ + mockResponse(t, http.StatusOK, current)(w, r) + }, + "PATCH /orgs/{org}/properties/schema": func(w http.ResponseWriter, r *http.Request) { + requests++ + body, _ := io.ReadAll(r.Body) + require.NoError(t, json.Unmarshal(body, &captured)) + mockResponse(t, http.StatusOK, captured.Properties)(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "organization", + "org": "octo", + "properties": []any{ + map[string]any{ + "property_name": "environment", + "required": false, + }, + map[string]any{ + "property_name": "legacy", + "description": nil, + "allowed_values": []any{}, + "require_explicit_values": false, + }, + map[string]any{ + "property_name": "service", + "value_type": "string", + "required": false, + "default_value": nil, + "description": "", + "allowed_values": nil, + "values_editable_by": nil, + "require_explicit_values": false, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, 2, requests) + require.Len(t, captured.Properties, 3) + + environment := captured.Properties[0] + assert.Equal(t, "environment", environment["property_name"]) + assert.Equal(t, "single_select", environment["value_type"]) + assert.Equal(t, false, environment["required"]) + assert.Equal(t, "production", environment["default_value"]) + assert.Equal(t, "Deployment environment", environment["description"]) + assert.Equal(t, []any{"production", "staging"}, environment["allowed_values"]) + assert.Equal(t, "org_and_repo_actors", environment["values_editable_by"]) + assert.Equal(t, true, environment["require_explicit_values"]) + assert.NotContains(t, environment, "source_type") + assert.NotContains(t, environment, "url") + + legacy := captured.Properties[1] + assert.Equal(t, "string", legacy["value_type"]) + assert.Equal(t, false, legacy["required"]) + assert.Contains(t, legacy, "default_value") + assert.Nil(t, legacy["default_value"]) + assert.Contains(t, legacy, "description") + assert.Nil(t, legacy["description"]) + assert.Contains(t, legacy, "allowed_values") + assert.Empty(t, legacy["allowed_values"]) + assert.Contains(t, legacy, "values_editable_by") + assert.Nil(t, legacy["values_editable_by"]) + assert.Equal(t, false, legacy["require_explicit_values"]) + + service := captured.Properties[2] + assert.Equal(t, "string", service["value_type"]) + assert.Equal(t, false, service["required"]) + assert.Contains(t, service, "default_value") + assert.Nil(t, service["default_value"]) + assert.Equal(t, "", service["description"]) + assert.Contains(t, service, "allowed_values") + assert.Nil(t, service["allowed_values"]) + assert.Contains(t, service, "values_editable_by") + assert.Nil(t, service["values_editable_by"]) + assert.Equal(t, false, service["require_explicit_values"]) + }) + + t.Run("enterprise level: merges an existing definition", func(t *testing.T) { + requests := 0 + var captured struct { + Properties []map[string]any `json:"properties"` + } + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /enterprises/{enterprise}/properties/schema": func(w http.ResponseWriter, r *http.Request) { + requests++ + mockResponse(t, http.StatusOK, []map[string]any{{ + "property_name": "compliance", + "source_type": "enterprise", + "value_type": "multi_select", + "required": true, + "default_value": []string{"soc2"}, + "description": "Compliance frameworks", + "allowed_values": []string{"soc2", "fedramp"}, + "values_editable_by": "org_actors", + "require_explicit_values": true, + }})(w, r) + }, + "PATCH /enterprises/{enterprise}/properties/schema": func(w http.ResponseWriter, r *http.Request) { + requests++ + body, _ := io.ReadAll(r.Body) + require.NoError(t, json.Unmarshal(body, &captured)) + mockResponse(t, http.StatusOK, captured.Properties)(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "enterprise", + "enterprise": "acme", + "properties": []any{ + map[string]any{ + "property_name": "compliance", + "default_value": []any{"soc2", "fedramp"}, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, 2, requests) + require.Len(t, captured.Properties, 1) + assert.Equal(t, "multi_select", captured.Properties[0]["value_type"]) + assert.Equal(t, []any{"soc2", "fedramp"}, captured.Properties[0]["default_value"]) + assert.Equal(t, true, captured.Properties[0]["require_explicit_values"]) + }) + + t.Run("rejects invalid definition writes before patch", func(t *testing.T) { + tests := []struct { + name string + args map[string]any + current []map[string]any + getStatus int + expectedError string + expectedRequests int + }{ + { + name: "organization definition without value_type", + args: map[string]any{ + "level": "organization", + "org": "octo", + "properties": []any{map[string]any{"property_name": "environment"}}, + }, + expectedError: "value_type is required for new property", + expectedRequests: 1, + }, + { + name: "duplicate property names", + args: map[string]any{ + "level": "organization", + "org": "octo", + "properties": []any{ + map[string]any{"property_name": "environment"}, + map[string]any{"property_name": "environment"}, + }, + }, + expectedError: "duplicates", + }, + { + name: "inherited enterprise definition", + args: map[string]any{ + "level": "organization", + "org": "octo", + "properties": []any{map[string]any{"property_name": "environment"}}, + }, + current: []map[string]any{{ + "property_name": "environment", + "source_type": "enterprise", + "value_type": "string", + }}, + expectedError: "inherited from enterprise", + expectedRequests: 1, + }, + { + name: "GET failure", + args: map[string]any{ + "level": "organization", + "org": "octo", + "properties": []any{map[string]any{"property_name": "environment"}}, + }, + getStatus: http.StatusInternalServerError, + expectedError: "failed to get organization custom properties before updating", + expectedRequests: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + requests := 0 + status := tt.getStatus + if status == 0 { + status = http.StatusOK + } + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/properties/schema": func(w http.ResponseWriter, r *http.Request) { + requests++ + mockResponse(t, status, tt.current)(w, r) + }, + "PATCH /orgs/{org}/properties/schema": func(w http.ResponseWriter, _ *http.Request) { + requests++ + w.WriteHeader(http.StatusOK) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(tt.args) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tt.expectedError) + assert.Equal(t, tt.expectedRequests, requests) + }) + } + }) + + t.Run("unknown level returns an error", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "team", + "properties": []any{map[string]any{"property_name": "x"}}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "unknown level") + }) +} diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index 9cec4cb814..32669788c0 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -7,19 +7,19 @@ import ( "io" "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/v89/github" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetSecurityAlertInput, *DependabotAlertOutput]( ToolsetMetadataDependabot, mcp.Tool{ Name: "get_dependabot_alert", @@ -48,18 +48,15 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo }, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GetSecurityAlertInput) (*mcp.CallToolResult, *DependabotAlertOutput, error) { + if args.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - alertNumber, err := RequiredInt(args, "alertNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.AlertNumber == 0 { + return utils.NewToolResultError("missing required parameter: alertNumber"), nil, nil } client, err := deps.GetClient(ctx) @@ -67,10 +64,10 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, err } - alert, resp, err := client.Dependabot.GetRepoAlert(ctx, owner, repo, alertNumber) + alert, resp, err := client.Dependabot.GetRepoAlert(ctx, args.Owner, args.Repo, args.AlertNumber) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - dependabotErrMsg(fmt.Sprintf("failed to get alert with number '%d'", alertNumber), owner, repo, resp), + dependabotErrMsg(fmt.Sprintf("failed to get alert with number '%d'", args.AlertNumber), args.Owner, args.Repo, resp), resp, err, ), nil, nil @@ -95,8 +92,9 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo // visibility and embed attacker-influenceable advisory text, so the // label is always private-untrusted. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) - return result, nil, nil + return result, dependabotAlertOutput(alert), nil }, + normalizeSecurityIntegerArguments("alertNumber"), ) } @@ -128,7 +126,7 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server } WithCursorPagination(schema) - return NewTool( + return NewToolWithSchemaOptions[ListDependabotAlertsInput, DependabotAlertsOutput]( ToolsetMetadataDependabot, mcp.Tool{ Name: "list_dependabot_alerts", @@ -140,37 +138,26 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server InputSchema: schema, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - state, err := OptionalParam[string](args, "state") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - severity, err := OptionalParam[string](args, "severity") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + inventory.TypedSchemaOptions{ + ValidationInputSchema: inventory.CloneSchemaWithoutDefaults(schema), + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ListDependabotAlertsInput) (*mcp.CallToolResult, DependabotAlertsOutput, error) { + if args.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), DependabotAlertsOutput{}, nil } - - pagination, err := OptionalCursorPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), DependabotAlertsOutput{}, nil } + pagination := securityCursorPagination(args.PerPage, args.After) client, err := deps.GetClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, err + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), DependabotAlertsOutput{}, err } - alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, owner, repo, &github.ListAlertsOptions{ - State: ToStringPtr(state), - Severity: ToStringPtr(severity), + alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, args.Owner, args.Repo, &github.ListAlertsOptions{ + State: ToStringPtr(args.State), + Severity: ToStringPtr(args.Severity), ListCursorOptions: github.ListCursorOptions{ PerPage: pagination.PerPage, After: pagination.After, @@ -178,29 +165,37 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - dependabotErrMsg(fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), owner, repo, resp), + dependabotErrMsg(fmt.Sprintf("failed to list alerts for repository '%s/%s'", args.Owner, args.Repo), args.Owner, args.Repo, resp), resp, err, - ), nil, nil + ), DependabotAlertsOutput{}, 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, err + return utils.NewToolResultErrorFromErr("failed to read response body", err), DependabotAlertsOutput{}, err } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list alerts", resp, body), nil, nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list alerts", resp, body), DependabotAlertsOutput{}, nil } - response := map[string]any{ - "alerts": alerts, - "pageInfo": buildPageInfo(resp), + apiPageInfo := buildPageInfo(resp) + output := DependabotAlertsOutput{ + Alerts: mapSecurityOutputs(alerts, dependabotAlertOutput), + PageInfo: SecurityPageInfo(apiPageInfo), } + response := struct { + Alerts []*github.DependabotAlert `json:"alerts"` + PageInfo pageInfo `json:"pageInfo"` + }{ + Alerts: alerts, + PageInfo: apiPageInfo, + } r, err := json.Marshal(response) if err != nil { - return utils.NewToolResultErrorFromErr("failed to marshal alerts", err), nil, err + return utils.NewToolResultErrorFromErr("failed to marshal alerts", err), DependabotAlertsOutput{}, err } result := utils.NewToolResultText(string(r)) @@ -208,8 +203,9 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server // visibility and embed attacker-influenceable advisory text, so the // label is always private-untrusted. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) - return result, nil, nil + return result, output, nil }, + normalizeTypedReadArguments(nil, false), ) } diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go index dba0a5603f..089ed58faa 100644 --- a/pkg/github/dependabot_test.go +++ b/pkg/github/dependabot_test.go @@ -6,9 +6,8 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -17,7 +16,7 @@ func Test_GetDependabotAlert(t *testing.T) { // Verify tool definition toolDef := GetDependabotAlert(translations.NullTranslationHelper) tool := toolDef.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testSecurityToolSnapshot(t, tool) // Validate tool schema assert.Equal(t, "get_dependabot_alert", tool.Name) @@ -26,9 +25,9 @@ func Test_GetDependabotAlert(t *testing.T) { // Setup mock alert for success case mockAlert := &github.DependabotAlert{ - Number: github.Ptr(42), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/42"), + Number: new(42), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/security/dependabot/42"), } tests := []struct { @@ -129,7 +128,7 @@ func Test_ListDependabotAlerts(t *testing.T) { // Verify tool definition once toolDef := ListDependabotAlerts(translations.NullTranslationHelper) tool := toolDef.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testSecurityToolSnapshot(t, tool) assert.Equal(t, "list_dependabot_alerts", tool.Name) assert.NotEmpty(t, tool.Description) @@ -137,19 +136,19 @@ func Test_ListDependabotAlerts(t *testing.T) { // Setup mock alerts for success case criticalAlert := github.DependabotAlert{ - Number: github.Ptr(1), - HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/1"), - State: github.Ptr("open"), + Number: new(1), + HTMLURL: new("https://github.com/owner/repo/security/dependabot/1"), + State: new("open"), SecurityAdvisory: &github.DependabotSecurityAdvisory{ - Severity: github.Ptr("critical"), + Severity: new("critical"), }, } highSeverityAlert := github.DependabotAlert{ - Number: github.Ptr(2), - HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/2"), - State: github.Ptr("fixed"), + Number: new(2), + HTMLURL: new("https://github.com/owner/repo/security/dependabot/2"), + State: new("fixed"), SecurityAdvisory: &github.DependabotSecurityAdvisory{ - Severity: github.Ptr("high"), + Severity: new("high"), }, } diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index 0de3e3b279..0ede5ff9a7 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -6,18 +6,17 @@ import ( "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/translations" - "github.com/github/github-mcp-server/pkg/utils" - gogithub "github.com/google/go-github/v89/github" + + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/transport" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/lockdown" + "github.com/github/github-mcp-server/v2/pkg/observability" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/raw" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + gogithub "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) @@ -95,7 +94,7 @@ type ToolDependencies interface { GetContentWindowSize() int // IsFeatureEnabled checks if a feature flag is enabled. - IsFeatureEnabled(ctx context.Context, flagName string) bool + IsFeatureEnabled(ctx context.Context, flag string) bool // Logger returns the structured logger, optionally enriched with // request-scoped data from ctx. Integrators provide their own slog.Handler @@ -204,28 +203,25 @@ func (d BaseDeps) Metrics(ctx context.Context) metrics.Metrics { // GetRequestStateSealer implements RequestStateSealerProvider. func (d BaseDeps) GetRequestStateSealer() RequestStateSealer { return d.StateSealer } -// 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 +// IsFeatureEnabled checks if a feature flag is enabled. Request feature state +// is authoritative when present; the dependency checker is a fallback for +// direct handler invocation. Empty names and checker errors resolve false. +func (d BaseDeps) IsFeatureEnabled(ctx context.Context, flag string) bool { + return inventory.ResolveFeature(ctx, d.featureChecker, inventory.FeatureFlag(flag)) } // 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). // +// Use concrete input and output structs for migrated tools; registration then +// delegates schema inference and validation to mcp.AddTool. For example, define +// `type SearchInput struct { Query string `json:"query"` }` and a concrete +// `SearchOutput`, then use `NewTool[SearchInput, SearchOutput](...)`. Keep Out +// as any for tools that must retain the raw registration path. A final optional +// inventory.InputNormalizer argument can canonicalize legacy wire values +// (such as case-insensitive enums) before strict SDK schema validation. +// // The handler function receives deps extracted from context via MustDepsFromContext. // Ensure ContextWithDeps is called to inject deps before any tool handlers are invoked. // @@ -235,11 +231,33 @@ func NewTool[In, Out any]( tool mcp.Tool, scopeAccess inventory.ScopeAccess, handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error), + inputNormalizers ...inventory.InputNormalizer, ) inventory.ServerTool { - st := inventory.NewServerToolWithContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) { + return NewToolWithSchemaOptions( + toolset, + tool, + scopeAccess, + inventory.TypedSchemaOptions{}, + handler, + inputNormalizers..., + ) +} + +// NewToolWithSchemaOptions is like NewTool, with options for schema inference, +// runtime-only input validation schemas, and pre-decode compatibility checks. +// The original tool schema remains the schema advertised to clients. +func NewToolWithSchemaOptions[In, Out any]( + toolset inventory.ToolsetMetadata, + tool mcp.Tool, + scopeAccess inventory.ScopeAccess, + schemaOptions inventory.TypedSchemaOptions, + handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error), + inputNormalizers ...inventory.InputNormalizer, +) inventory.ServerTool { + st := inventory.NewServerToolWithContextHandlerAndSchemaOptions(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) { deps := MustDepsFromContext(ctx) return handler(ctx, deps, req, args) - }) + }, schemaOptions, inputNormalizers...) st.ScopeAccess = scopeAccess return st } @@ -495,18 +513,9 @@ 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 +// IsFeatureEnabled checks if a feature flag is enabled. Request feature state +// is authoritative when present; the dependency checker is a fallback for +// direct handler invocation. +func (d *RequestDeps) IsFeatureEnabled(ctx context.Context, flag string) bool { + return inventory.ResolveFeature(ctx, d.featureChecker, inventory.FeatureFlag(flag)) } diff --git a/pkg/github/dependencies_test.go b/pkg/github/dependencies_test.go index 223fc8aca4..b99ac2aa00 100644 --- a/pkg/github/dependencies_test.go +++ b/pkg/github/dependencies_test.go @@ -2,7 +2,9 @@ package github_test import ( "context" + "encoding/json" "errors" + "fmt" "log/slog" "net/http" "net/http/httptest" @@ -11,12 +13,14 @@ import ( "sync" "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/observability" - "github.com/github/github-mcp-server/pkg/observability/metrics" - "github.com/github/github-mcp-server/pkg/translations" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/observability" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -27,6 +31,94 @@ func testExporters() observability.Exporters { return obs } +func TestNewToolWithSchemaOptionsPointerInputAndNullableOutput(t *testing.T) { + type input struct { + Query string `json:"query"` + } + type output struct { + Query string `json:"query"` + } + for _, legacy := range []bool{false, true} { + t.Run(fmt.Sprintf("legacy=%t", legacy), func(t *testing.T) { + options := &mcp.ServerOptions{} + if legacy { + options.SupportedProtocolVersions = []string{"2025-11-25"} + } + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1"}, options) + if legacy { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if method == "server/discover" { + return nil, errors.New("legacy initialize required") + } + return next(ctx, method, req) + } + }) + } + deps := github.NewRequestDeps(newRequestDepsAPIHostResolver(t, "https://api.github.com"), + "test", false, nil, translations.NullTranslationHelper, 0, nil, testExporters()) + server.AddReceivingMiddleware(github.InjectDepsMiddleware(deps)) + tool := github.NewToolWithSchemaOptions( + inventory.ToolsetMetadata{ID: "test"}, + mcp.Tool{Name: "pointer_tool"}, + inventory.ScopeAccess{}, + inventory.TypedSchemaOptions{}, + func(_ context.Context, gotDeps github.ToolDependencies, _ *mcp.CallToolRequest, args *input) (*mcp.CallToolResult, *output, error) { + require.Same(t, deps, gotDeps) + require.NotNil(t, args) + result := &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: args.Query}}} + if args.Query == "null" { + return result, nil, nil + } + return result, &output{Query: args.Query}, nil + }, + ) + require.NotPanics(t, func() { tool.RegisterFunc(server, deps) }) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + defer serverSession.Close() + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1"}, nil) + session, err := client.Connect(context.Background(), clientTransport, nil) + require.NoError(t, err) + defer session.Close() + if legacy { + assert.Equal(t, "2025-11-25", session.InitializeResult().ProtocolVersion) + } + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + inputJSON, err := json.Marshal(list.Tools[0].InputSchema) + require.NoError(t, err) + assert.Contains(t, string(inputJSON), `"type":"object"`) + for _, query := range []string{"value", "null"} { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"query": query}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + if legacy { + assert.Nil(t, list.Tools[0].OutputSchema) + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1) + assert.Equal(t, query, result.Content[0].(*mcp.TextContent).Text) + } else { + schemaJSON, err := json.Marshal(list.Tools[0].OutputSchema) + require.NoError(t, err) + assert.Contains(t, string(schemaJSON), `"null"`) + valueJSON, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + if query == "null" { + assert.JSONEq(t, `null`, string(valueJSON)) + } else { + assert.JSONEq(t, `{"query":"value"}`, string(valueJSON)) + } + } + } + }) + } +} + type requestDepsAPIHostResolver struct { endpoint *url.URL } diff --git a/pkg/github/discussion_notification_contracts_test.go b/pkg/github/discussion_notification_contracts_test.go new file mode 100644 index 0000000000..11d6ea26d9 --- /dev/null +++ b/pkg/github/discussion_notification_contracts_test.go @@ -0,0 +1,272 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/sanitize" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscussionReadRequiredArguments(t *testing.T) { + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocol, func(t *testing.T) { + calls := 0 + deps := BaseDeps{GQLClient: githubv4.NewClient(&http.Client{ + Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + var request struct { + Variables map[string]any `json:"variables"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + assert.Equal(t, "", request.Variables["owner"]) + assert.Equal(t, "", request.Variables["repo"]) + assert.Equal(t, float64(0), request.Variables["discussionNumber"]) + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "errors": []any{map[string]any{"message": "empty coordinates rejected by GitHub"}}, + })) + })}, + })} + server := mcp.NewServer(&mcp.Implementation{Name: "required-arguments-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + for _, tool := range []inventory.ServerTool{ + GetDiscussion(translations.NullTranslationHelper), + GetDiscussionComments(translations.NullTranslationHelper), + } { + tool.RegisterFunc(server, deps) + } + client := connectCommentVisibilityClient(t, server, protocol) + for _, name := range []string{"get_discussion", "get_discussion_comments"} { + // A wholly empty object must not be normalized into zero-valued + // owner/repo/discussionNumber keys that would satisfy the SDK's + // required-property validation and reach GraphQL. + before := calls + result, err := client.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: map[string]any{}}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + text := getTextResult(t, result).Text + assert.Contains(t, text, "owner") + assert.Contains(t, text, "repo") + assert.Contains(t, text, "discussionNumber") + assert.Equal(t, before, calls, "empty object must not reach GitHub") + + for _, missing := range []string{"owner", "repo", "discussionNumber"} { + args := map[string]any{"owner": "owner", "repo": "repo", "discussionNumber": "1"} + delete(args, missing) + before := calls + result, err := client.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + assert.Contains(t, getTextResult(t, result).Text, missing) + assert.Equal(t, before, calls, "missing required property must not reach GitHub") + } + result, err = client.CallTool(context.Background(), &mcp.CallToolParams{ + Name: name, Arguments: map[string]any{"OWNER": "", "REPO": "", "DISCUSSIONNUMBER": 0}, + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "empty coordinates rejected by GitHub") + } + assert.Equal(t, 2, calls, "present empty values retain main's WeakDecode/API behavior") + }) + } +} + +func TestDiscussionNotificationEmptyAndNullOutputs(t *testing.T) { + for _, tc := range []struct { + name string + api string + tool inventory.ServerTool + args map[string]any + legacy string + modern string + }{ + {"notifications null", "null", ListNotifications(translations.NullTranslationHelper), map[string]any{}, "null", "[]"}, + {"notifications empty", "[]", ListNotifications(translations.NullTranslationHelper), map[string]any{}, "[]", "[]"}, + {"notification null item", "[null]", ListNotifications(translations.NullTranslationHelper), map[string]any{}, "[null]", "[null]"}, + {"notification null detail", "null", GetNotificationDetails(translations.NullTranslationHelper), map[string]any{"notificationID": "123"}, "null", "{}"}, + } { + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(tc.name+"/"+protocol, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, &http.Client{ + Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(tc.api)) + require.NoError(t, err) + })}, + })} + server := mcp.NewServer(&mcp.Implementation{Name: "empty-output-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + tc.tool.RegisterFunc(server, deps) + client := connectCommentVisibilityClient(t, server, protocol) + result, err := client.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.False(t, result.IsError) + if protocol == "2025-11-25" { + assert.Equal(t, tc.legacy, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } else { + assert.JSONEq(t, tc.modern, mustMarshalJSON(t, result.StructuredContent)) + assert.JSONEq(t, tc.modern, getTextResult(t, result).Text) + resolved, err := tc.tool.Tool.OutputSchema.(*jsonschema.Schema).Resolve(nil) + require.NoError(t, err) + var output any + require.NoError(t, json.Unmarshal([]byte(tc.modern), &output)) + assert.NoError(t, resolved.Validate(output)) + } + }) + } + } +} + +func TestDiscussionStructuredSanitizationMatchesLegacy(t *testing.T) { + const title = "Discussion" + const body = "Discussion body" + deps := BaseDeps{GQLClient: githubv4.NewClient(&http.Client{ + Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "repository": map[string]any{"discussion": map[string]any{ + "number": 1, "title": title, "body": body, + "createdAt": "2026-01-01T00:00:00Z", "category": map[string]any{"name": "General"}, + }}, + }})) + })}, + })} + server := mcp.NewServer(&mcp.Implementation{Name: "sanitized-output-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + tool := GetDiscussion(translations.NullTranslationHelper) + tool.RegisterFunc(server, deps) + client := connectCommentVisibilityClient(t, server, inventory.ProtocolVersionMultiRoundTrip) + result, err := client.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"owner": "owner", "repo": "repo", "discussionNumber": 1}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + var legacy, modern map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &legacy)) + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &modern)) + assert.Equal(t, legacy, modern, "modern JSON text and structured DTO must match") + assert.Equal(t, sanitize.PlainText(title), modern["title"]) + assert.Equal(t, sanitize.Content(body), modern["body"]) + assert.Equal(t, legacy["title"], modern["title"]) + assert.Equal(t, legacy["body"], modern["body"]) +} + +func TestNotificationProjectionTrimsHypermedia(t *testing.T) { + notification := &github.Notification{ + ID: new("123"), URL: new("https://api.github.com/notifications/threads/123"), + Subject: &github.NotificationSubject{ + Title: new("Title"), Type: new("Issue"), + URL: new("https://api.github.com/repos/owner/repo/issues/1"), + LatestCommentURL: new("https://api.github.com/repos/owner/repo/issues/comments/2"), + }, + } + before := `{"id":"123","subject":{"title":"Title","url":"https://api.github.com/repos/owner/repo/issues/1","latest_comment_url":"https://api.github.com/repos/owner/repo/issues/comments/2","type":"Issue"},"url":"https://api.github.com/notifications/threads/123"}` + after := mustMarshalJSON(t, notificationOutput(notification)) + assert.JSONEq(t, `{"id":"123","subject":{"title":"Title","type":"Issue","url":"https://api.github.com/repos/owner/repo/issues/1"}}`, after) + assert.Less(t, len(after), len(before)) + assert.Contains(t, mustMarshalJSON(t, notification), "latest_comment_url", "legacy formatter must retain its existing links") + t.Logf("populated notification projection: %d -> %d JSON bytes", len(before), len(after)) +} + +func TestDiscussionEmptyCollections(t *testing.T) { + deps := BaseDeps{GQLClient: githubv4.NewClient(&http.Client{ + Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Query string `json:"query"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + repository := map[string]any{"discussions": map[string]any{"nodes": nil}} + if strings.Contains(request.Query, "discussionCategories(") { + repository = map[string]any{"discussionCategories": map[string]any{"nodes": nil}} + } else if strings.Contains(request.Query, "comments(") { + repository = map[string]any{"discussion": map[string]any{"comments": map[string]any{"nodes": nil}}} + } + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "repository": repository, + }})) + })}, + })} + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocol, func(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "empty-collections-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + for _, tool := range []inventory.ServerTool{ + ListDiscussions(translations.NullTranslationHelper), + GetDiscussionComments(translations.NullTranslationHelper), + ListDiscussionCategories(translations.NullTranslationHelper), + } { + tool.RegisterFunc(server, deps) + } + client := connectCommentVisibilityClient(t, server, protocol) + for _, tc := range []struct { + name, collection string + }{ + {"list_discussions", "discussions"}, + {"get_discussion_comments", "comments"}, + {"list_discussion_categories", "categories"}, + } { + result, err := client.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tc.name, Arguments: map[string]any{"owner": "owner", "repo": "repo", "discussionNumber": 1}, + }) + require.NoError(t, err) + require.False(t, result.IsError, "%s", result) + var text map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &text)) + if protocol == "2025-11-25" { + assert.Nil(t, text[tc.collection], "legacy nil collections retain JSON null") + assert.Nil(t, result.StructuredContent) + } else { + var modern map[string]any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &modern)) + assert.Equal(t, []any{}, modern[tc.collection]) + assert.Equal(t, text, modern, "modern empty collections use the same DTO in both representations") + } + } + }) + } +} + +func TestNotificationOutputProviderValuesAndReferenceSchema(t *testing.T) { + schema := notificationOutputSchema(false) + assert.NotContains(t, schema.Properties, "url") + assert.Contains(t, schema.Properties["subject"].Properties, "url") + assert.NotContains(t, schema.Properties["subject"].Properties, "latest_comment_url") + assert.Contains(t, schema.Properties["repository"].Properties, "html_url") + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + assert.Empty(t, schema.Properties["reason"].Enum) + assert.Empty(t, schema.Properties["subject"].Properties["type"].Enum) + for _, reason := range []string{ + "agent_session_finished", "approval_requested", "assign", "author", "ci_activity", + "comment", "invitation", "manual", "member_feature_requested", "mention", + "review_requested", "security_advisory_credit", "security_alert", + "state_change", "subscribed", "team_mention", "future_reason", + } { + assert.NoError(t, resolved.Validate(map[string]any{"reason": reason}), reason) + } + for _, subjectType := range []string{ + "CheckSuite", "Commit", "Discussion", "Issue", "PullRequest", "Release", + "RepositoryInvitation", "SecurityAdvisory", "FutureSubject", + } { + assert.NoError(t, resolved.Validate(map[string]any{"subject": map[string]any{"type": subjectType}}), subjectType) + } + assert.Error(t, resolved.Validate(map[string]any{"reason": 42})) + assert.Error(t, resolved.Validate(map[string]any{"subject": map[string]any{"type": 42}})) + subscription := discussionNotificationOutputSchema[NotificationSubscriptionOutput]() + for _, field := range []string{"url", "thread_url", "repository_url"} { + assert.NotContains(t, subscription.Properties, field) + } +} diff --git a/pkg/github/discussion_notification_inputs.go b/pkg/github/discussion_notification_inputs.go new file mode 100644 index 0000000000..05c52db19c --- /dev/null +++ b/pkg/github/discussion_notification_inputs.go @@ -0,0 +1,183 @@ +package github + +import ( + "encoding/json" + "strings" + + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/go-viper/mapstructure/v2" +) + +type ListNotificationsInput struct { + Filter *string `json:"filter,omitempty"` + Since *string `json:"since,omitempty"` + Before *string `json:"before,omitempty"` + Owner *string `json:"owner,omitempty"` + Repo *string `json:"repo,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` + After *string `json:"after,omitempty"` +} + +type DismissNotificationInput struct { + ThreadID string `json:"threadID"` + State string `json:"state"` +} + +type MarkAllNotificationsReadInput struct { + LastReadAt *string `json:"lastReadAt,omitempty"` + Owner *string `json:"owner,omitempty"` + Repo *string `json:"repo,omitempty"` +} + +type GetNotificationDetailsInput struct { + NotificationID string `json:"notificationID"` +} + +type ManageNotificationSubscriptionInput struct { + NotificationID string `json:"notificationID"` + Action string `json:"action"` +} + +type ManageRepositoryNotificationSubscriptionInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Action string `json:"action"` +} + +type ListDiscussionsInput struct { + Owner string `json:"owner"` + Repo *string `json:"repo,omitempty"` + Category *string `json:"category,omitempty"` + OrderBy *string `json:"orderBy,omitempty"` + Direction *string `json:"direction,omitempty"` + PerPage *int `json:"perPage,omitempty"` + After *string `json:"after,omitempty"` +} + +type GetDiscussionInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + DiscussionNumber int32 `json:"discussionNumber"` +} + +type GetDiscussionCommentsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + DiscussionNumber int32 `json:"discussionNumber"` + IncludeReplies *bool `json:"includeReplies,omitempty"` + PerPage *int `json:"perPage,omitempty"` + After *string `json:"after,omitempty"` +} + +type DiscussionCommentWriteInput struct { + Method string `json:"method"` + Owner *string `json:"owner,omitempty"` + Repo *string `json:"repo,omitempty"` + DiscussionNumber *int `json:"discussionNumber,omitempty"` + Body *string `json:"body,omitempty"` + CommentNodeID *string `json:"commentNodeID,omitempty"` +} + +type ListDiscussionCategoriesInput struct { + Owner string `json:"owner"` + Repo *string `json:"repo,omitempty"` +} + +// Preserve presence and the existing parameter validators at the compatibility +// boundary. In particular, an explicit perPage: 0 differs from an omitted value +// for discussion comments, and mutation parameters are required per method. +func discussionNotificationArguments(input any) (map[string]any, error) { + raw, err := json.Marshal(input) + if err != nil { + return nil, err + } + var args map[string]any + err = json.Unmarshal(raw, &args) + return args, err +} + +func normalizeDiscussionReadArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + // These two tools historically used WeakDecode: retain its case-insensitive + // field matching, numeric-string conversion and fractional truncation. + var input GetDiscussionInput + if err := mapstructure.WeakDecode(args, &input); err != nil { + return nil, err + } + for field, value := range map[string]any{ + "owner": input.Owner, "repo": input.Repo, "discussionNumber": input.DiscussionNumber, + } { + for source := range args { + if strings.EqualFold(source, field) { + args[field] = value + break + } + } + } + return json.Marshal(args) +} + +func normalizeDiscussionNotificationIntegers(fields ...string) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + + for _, field := range fields { + if _, ok := args[field]; !ok { + continue + } + value, err := OptionalIntParam(args, field) + if err != nil { + return nil, err + } + if value == 0 { + switch field { + case "perPage": + value = 30 + case "page": + value = 1 + } + } + args[field] = value + } + return json.Marshal(args) + } +} + +func normalizeDiscussionCommentWriteArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + method, _ := args["method"].(string) + used := map[string]bool{"method": true} + switch method { + case "add", "reply": + used["owner"], used["repo"], used["discussionNumber"], used["body"] = true, true, true, true + if method == "reply" { + used["commentNodeID"] = true + } + case "update": + used["commentNodeID"], used["body"] = true, true + case "delete", "mark_answer", "unmark_answer": + used["commentNodeID"] = true + } + // Unused method-specific parameters were never decoded by the legacy + // handler, even when their values did not match the advertised schema. + for _, field := range []string{"owner", "repo", "discussionNumber", "body", "commentNodeID"} { + if !used[field] { + delete(args, field) + } + } + normalized, err := json.Marshal(args) + if err != nil { + return nil, err + } + return normalizeDiscussionNotificationIntegers("discussionNumber")(normalized) +} diff --git a/pkg/github/discussion_notification_outputs.go b/pkg/github/discussion_notification_outputs.go new file mode 100644 index 0000000000..ebe4e4c8ad --- /dev/null +++ b/pkg/github/discussion_notification_outputs.go @@ -0,0 +1,275 @@ +package github + +import ( + "encoding/json" + "time" + + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" +) + +type NotificationSubjectOutput struct { + Title *string `json:"title,omitempty"` + Type *string `json:"type,omitempty" jsonschema:"Subject type reported by GitHub; new provider values are preserved."` + URL *string `json:"url,omitempty" jsonschema:"API reference to the underlying notification subject."` +} + +type NotificationRepositoryOutput struct { + ID *int64 `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + FullName *string `json:"full_name,omitempty"` + Private *bool `json:"private,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` +} + +type NotificationOutput struct { + ID *string `json:"id,omitempty"` + Repository *NotificationRepositoryOutput `json:"repository,omitempty"` + Subject *NotificationSubjectOutput `json:"subject,omitempty"` + Reason *string `json:"reason,omitempty" jsonschema:"Notification reason reported by GitHub; new provider values are preserved."` + Unread *bool `json:"unread,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty" jsonschema:"Last update time (RFC3339)."` + LastReadAt *time.Time `json:"last_read_at,omitempty" jsonschema:"Last acknowledgement time (RFC3339)."` +} + +func notificationOutput(n *github.Notification) *NotificationOutput { + if n == nil { + return nil + } + out := &NotificationOutput{ + ID: n.ID, Reason: n.Reason, Unread: n.Unread, + } + if n.UpdatedAt != nil { + out.UpdatedAt = &n.UpdatedAt.Time + } + if n.LastReadAt != nil { + out.LastReadAt = &n.LastReadAt.Time + } + if n.Subject != nil { + out.Subject = &NotificationSubjectOutput{ + Title: n.Subject.Title, Type: n.Subject.Type, URL: n.Subject.URL, + } + } + if n.Repository != nil { + out.Repository = &NotificationRepositoryOutput{ + ID: n.Repository.ID, Name: n.Repository.Name, FullName: n.Repository.FullName, + Private: n.Repository.Private, HTMLURL: n.Repository.HTMLURL, + } + } + return out +} + +type NotificationStatusOutput struct { + Message string `json:"message"` +} + +type NotificationSubscriptionOutput struct { + Subscribed *bool `json:"subscribed,omitempty"` + Ignored *bool `json:"ignored,omitempty"` + Reason *string `json:"reason,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty" jsonschema:"Subscription creation time (RFC3339)."` +} + +type NotificationSubscriptionResult struct { + Subscription *NotificationSubscriptionOutput `json:"subscription,omitempty"` + Message string `json:"message,omitempty"` +} + +func (out NotificationSubscriptionResult) MarshalJSON() ([]byte, error) { + if out.Subscription != nil { + return json.Marshal(struct { + Subscription *NotificationSubscriptionOutput `json:"subscription"` + }{out.Subscription}) + } + return json.Marshal(NotificationStatusOutput{Message: out.Message}) +} + +func notificationSubscriptionOutput(s *github.Subscription) *NotificationSubscriptionResult { + out := &NotificationSubscriptionOutput{} + if s != nil { + out.Subscribed = s.Subscribed + out.Ignored = s.Ignored + out.Reason = s.Reason + if s.CreatedAt != nil { + out.CreatedAt = &s.CreatedAt.Time + } + } + return &NotificationSubscriptionResult{Subscription: out} +} + +func notificationSubscriptionResultSchema() *jsonschema.Schema { + subscription := discussionNotificationOutputSchema[NotificationSubscriptionOutput]() + return &jsonschema.Schema{ + Type: "object", + OneOf: []*jsonschema.Schema{ + { + Type: "object", + Properties: map[string]*jsonschema.Schema{"subscription": subscription}, + Required: []string{"subscription"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + }, + { + Type: "object", + Properties: map[string]*jsonschema.Schema{"message": {Type: "string"}}, + Required: []string{"message"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + }, + }, + } + +} + +func discussionNotificationOutputSchema[Out any]() *jsonschema.Schema { + schema, err := jsonschema.For[Out](nil) + if err != nil { + panic(err) + } + return schema +} + +func notificationOutputSchema(list bool) *jsonschema.Schema { + schema := discussionNotificationOutputSchema[NotificationOutput]() + if list { + schema.Type = "" + schema.Types = []string{"object", "null"} + listSchema := discussionNotificationOutputSchema[[]*NotificationOutput]() + listSchema.Items = schema + return listSchema + } + return schema +} + +type DiscussionPageInfoOutput struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + StartCursor string `json:"startCursor"` + EndCursor string `json:"endCursor"` +} + +type DiscussionListItemOutput struct { + Number *int `json:"number,omitempty"` + Title *string `json:"title,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty" jsonschema:"Creation time (RFC3339)."` + UpdatedAt *time.Time `json:"updated_at,omitempty" jsonschema:"Last update time (RFC3339)."` + Author string `json:"author"` + Category string `json:"category"` +} + +type ListDiscussionsOutput struct { + Discussions []*DiscussionListItemOutput `json:"discussions"` + PageInfo DiscussionPageInfoOutput `json:"pageInfo"` + TotalCount int `json:"totalCount"` +} + +type DiscussionCategoryOutput struct { + Name string `json:"name"` +} + +type DiscussionOutput struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + Closed bool `json:"closed"` + IsAnswered bool `json:"isAnswered"` + CreatedAt time.Time `json:"createdAt" jsonschema:"Creation time (RFC3339)."` + Category DiscussionCategoryOutput `json:"category"` + AnswerChosenAt *time.Time `json:"answerChosenAt,omitempty" jsonschema:"Answer selection time (RFC3339), when selected."` +} + +type DiscussionReplyOutput struct { + ID string `json:"id"` + Body string `json:"body"` + IsAnswer bool `json:"isAnswer,omitempty"` +} + +type DiscussionCommentOutput struct { + ID string `json:"id"` + Body string `json:"body"` + IsAnswer bool `json:"isAnswer,omitempty"` + Replies []DiscussionReplyOutput `json:"replies,omitempty"` + ReplyTotalCount int `json:"replyTotalCount,omitempty" jsonschema:"Total replies, including replies beyond the returned maximum of 100."` +} + +type DiscussionCommentsOutput struct { + Comments []DiscussionCommentOutput `json:"comments"` + PageInfo DiscussionPageInfoOutput `json:"pageInfo"` + TotalCount int `json:"totalCount"` +} + +func discussionCommentOutputs(comments []MinimalDiscussionComment) []DiscussionCommentOutput { + out := make([]DiscussionCommentOutput, 0, len(comments)) + for _, comment := range comments { + item := DiscussionCommentOutput{ + ID: comment.ID, Body: comment.Body, IsAnswer: comment.IsAnswer, + ReplyTotalCount: comment.ReplyTotalCount, + } + for _, reply := range comment.Replies { + item.Replies = append(item.Replies, DiscussionReplyOutput{ + ID: reply.ID, Body: reply.Body, IsAnswer: reply.IsAnswer, + }) + } + out = append(out, item) + } + return out +} + +type DiscussionCategoryItemOutput struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type DiscussionCategoriesOutput struct { + Categories []DiscussionCategoryItemOutput `json:"categories"` + PageInfo DiscussionPageInfoOutput `json:"pageInfo"` + TotalCount int `json:"totalCount"` +} + +type DiscussionAnswerOutput struct { + DiscussionID string `json:"discussionID"` + DiscussionURL string `json:"discussionURL"` +} + +// The comment mutations return id/url; answer mutations return a discussion +// reference instead. Marshal the selected concrete variant without an envelope. +type DiscussionCommentWriteOutput struct { + Comment *MinimalResponse + Answer *DiscussionAnswerOutput +} + +func (out DiscussionCommentWriteOutput) MarshalJSON() ([]byte, error) { + if out.Answer != nil { + return json.Marshal(out.Answer) + } + if out.Comment != nil { + return json.Marshal(out.Comment) + } + // The SDK validates an unpointered zero value even for IsError results. + // The inventory middleware removes this zero output before it is sent. + return json.Marshal(MinimalResponse{}) +} + +func discussionCommentWriteOutputSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + OneOf: []*jsonschema.Schema{ + { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "id": {Type: "string"}, "url": {Type: "string"}, + }, + Required: []string{"id", "url"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + }, + { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "discussionID": {Type: "string"}, "discussionURL": {Type: "string"}, + }, + Required: []string{"discussionID", "discussionURL"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + }, + }, + } +} diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 9ea31b2ebf..2de3b3fd95 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -6,14 +6,13 @@ import ( "fmt" "strings" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/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/v89/github" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/sanitize" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -99,16 +98,16 @@ type WithCategoryNoOrder struct { func fragmentToDiscussion(fragment NodeFragment) *github.Discussion { return &github.Discussion{ - Number: github.Ptr(int(fragment.Number)), - Title: github.Ptr(sanitize.Sanitize(string(fragment.Title))), - HTMLURL: github.Ptr(string(fragment.URL)), + Number: new(int(fragment.Number)), + Title: new(sanitize.PlainText(string(fragment.Title))), + HTMLURL: new(string(fragment.URL)), CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time}, UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time}, User: &github.User{ - Login: github.Ptr(string(fragment.Author.Login)), + Login: new(string(fragment.Author.Login)), }, DiscussionCategory: &github.DiscussionCategory{ - Name: github.Ptr(string(fragment.Category.Name)), + Name: new(string(fragment.Category.Name)), }, } } @@ -127,11 +126,12 @@ func getQueryType(useOrdering bool, categoryID *githubv4.ID) any { } func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ListDiscussionsInput, *ListDiscussionsOutput]( ToolsetMetadataDiscussions, mcp.Tool{ - Name: "list_discussions", - Description: t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation."), + Name: "list_discussions", + OutputSchema: discussionNotificationOutputSchema[ListDiscussionsOutput](), + Description: t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"), ReadOnlyHint: true, @@ -166,7 +166,11 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool }), }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListDiscussionsInput) (*mcp.CallToolResult, *ListDiscussionsOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -278,17 +282,34 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool // Discussion content is user-authored (untrusted); confidentiality // follows repo visibility. result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoUserContent) - return result, nil, nil + items := make([]*DiscussionListItemOutput, 0, len(discussions)) + for _, discussion := range discussions { + items = append(items, &DiscussionListItemOutput{ + Number: discussion.Number, Title: discussion.Title, HTMLURL: discussion.HTMLURL, + CreatedAt: &discussion.CreatedAt.Time, UpdatedAt: &discussion.UpdatedAt.Time, + Author: discussion.User.GetLogin(), Category: discussion.DiscussionCategory.GetName(), + }) + } + return result, &ListDiscussionsOutput{ + Discussions: items, + PageInfo: DiscussionPageInfoOutput{ + HasNextPage: pageInfo.HasNextPage, HasPreviousPage: pageInfo.HasPreviousPage, + StartCursor: string(pageInfo.StartCursor), EndCursor: string(pageInfo.EndCursor), + }, + TotalCount: int(totalCount), + }, nil }, + normalizeDiscussionNotificationIntegers("perPage"), ) } func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetDiscussionInput, *DiscussionOutput]( ToolsetMetadataDiscussions, mcp.Tool{ - Name: "get_discussion", - Description: t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID"), + Name: "get_discussion", + OutputSchema: discussionNotificationOutputSchema[DiscussionOutput](), + Description: t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"), ReadOnlyHint: true, @@ -313,16 +334,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - // Decode params - var params struct { - Owner string - Repo string - DiscussionNumber int32 - } - if err := mapstructure.WeakDecode(args, ¶ms); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, params GetDiscussionInput) (*mcp.CallToolResult, *DiscussionOutput, error) { client, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil @@ -361,8 +373,8 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { // like ListDiscussions and GetDiscussionComments). response := map[string]any{ "number": int(d.Number), - "title": sanitize.Sanitize(string(d.Title)), - "body": sanitize.Sanitize(string(d.Body)), + "title": sanitize.PlainText(string(d.Title)), + "body": sanitize.Content(string(d.Body)), "url": string(d.URL), "closed": bool(d.Closed), "isAnswered": bool(d.IsAnswered), @@ -386,17 +398,28 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { // Discussion content is user-authored (untrusted); confidentiality // follows repo visibility. result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent) - return result, nil, nil + output := &DiscussionOutput{ + Number: int(d.Number), Title: sanitize.PlainText(string(d.Title)), + Body: sanitize.Content(string(d.Body)), HTMLURL: string(d.URL), + Closed: bool(d.Closed), IsAnswered: bool(d.IsAnswered), CreatedAt: d.CreatedAt.Time, + Category: DiscussionCategoryOutput{Name: string(d.Category.Name)}, + } + if d.AnswerChosenAt != nil { + output.AnswerChosenAt = &d.AnswerChosenAt.Time + } + return result, output, nil }, + normalizeDiscussionReadArguments, ) } func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetDiscussionCommentsInput, *DiscussionCommentsOutput]( ToolsetMetadataDiscussions, mcp.Tool{ - Name: "get_discussion_comments", - Description: t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion"), + Name: "get_discussion_comments", + OutputSchema: discussionNotificationOutputSchema[DiscussionCommentsOutput](), + Description: t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"), ReadOnlyHint: true, @@ -425,15 +448,10 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve }), }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - // Decode params - var params struct { - Owner string - Repo string - DiscussionNumber int32 - } - if err := mapstructure.WeakDecode(args, ¶ms); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, params GetDiscussionCommentsInput) (*mcp.CallToolResult, *DiscussionCommentsOutput, error) { + args, err := discussionNotificationArguments(params) + if err != nil { + return nil, nil, err } includeReplies, err := OptionalParam[bool](args, "includeReplies") @@ -582,22 +600,32 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve // Discussion comments are user-authored (untrusted); confidentiality // follows repo visibility. result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent) - return result, nil, nil + return result, &DiscussionCommentsOutput{ + Comments: discussionCommentOutputs(comments), + PageInfo: DiscussionPageInfoOutput{ + HasNextPage: bool(pageInfo.HasNextPage), HasPreviousPage: bool(pageInfo.HasPreviousPage), + StartCursor: string(pageInfo.StartCursor), EndCursor: string(pageInfo.EndCursor), + }, + TotalCount: totalCount, + }, nil }, + normalizeDiscussionReadArguments, + normalizeDiscussionNotificationIntegers("perPage"), ) } func DiscussionCommentWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[DiscussionCommentWriteInput, *DiscussionCommentWriteOutput]( ToolsetMetadataDiscussions, mcp.Tool{ - Name: "discussion_comment_write", + Name: "discussion_comment_write", + OutputSchema: discussionCommentWriteOutputSchema(), 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), + DestructiveHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -640,7 +668,11 @@ Options are: }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input DiscussionCommentWriteInput) (*mcp.CallToolResult, *DiscussionCommentWriteOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -667,10 +699,12 @@ Options are: default: return utils.NewToolResultError("invalid method, must be one of: 'add', 'reply', 'update', 'delete', 'mark_answer', 'unmark_answer'"), nil, nil } - }) + }, + normalizeDiscussionCommentWriteArguments, + ) } -func addDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { +func addDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, *DiscussionCommentWriteOutput, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -724,15 +758,16 @@ func addDiscussionComment(ctx context.Context, client *githubv4.Client, args map } comment := mutation.AddDiscussionComment.Comment - out, err := json.Marshal(MinimalResponse{ + output := &MinimalResponse{ ID: fmt.Sprintf("%v", comment.ID), URL: string(comment.URL), - }) + } + out, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + return utils.NewToolResultText(string(out)), &DiscussionCommentWriteOutput{Comment: output}, nil } func requiredCommentNodeID(args map[string]any) (string, error) { @@ -746,7 +781,7 @@ func requiredCommentNodeID(args map[string]any) (string, error) { return commentNodeID, nil } -func replyToDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { +func replyToDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, *DiscussionCommentWriteOutput, error) { commentNodeID, err := requiredCommentNodeID(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -834,18 +869,19 @@ func replyToDiscussionComment(ctx context.Context, client *githubv4.Client, args } comment := mutation.AddDiscussionComment.Comment - out, err := json.Marshal(MinimalResponse{ + output := &MinimalResponse{ ID: fmt.Sprintf("%v", comment.ID), URL: string(comment.URL), - }) + } + out, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + return utils.NewToolResultText(string(out)), &DiscussionCommentWriteOutput{Comment: output}, nil } -func updateDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { +func updateDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, *DiscussionCommentWriteOutput, error) { commentNodeID, err := requiredCommentNodeID(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -874,18 +910,19 @@ func updateDiscussionComment(ctx context.Context, client *githubv4.Client, args } comment := mutation.UpdateDiscussionComment.Comment - out, err := json.Marshal(MinimalResponse{ + output := &MinimalResponse{ ID: fmt.Sprintf("%v", comment.ID), URL: string(comment.URL), - }) + } + out, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + return utils.NewToolResultText(string(out)), &DiscussionCommentWriteOutput{Comment: output}, nil } -func deleteDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { +func deleteDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, *DiscussionCommentWriteOutput, error) { commentNodeID, err := requiredCommentNodeID(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -909,18 +946,19 @@ func deleteDiscussionComment(ctx context.Context, client *githubv4.Client, args } comment := mutation.DeleteDiscussionComment.Comment - out, err := json.Marshal(MinimalResponse{ + output := &MinimalResponse{ ID: fmt.Sprintf("%v", comment.ID), URL: string(comment.URL), - }) + } + out, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + return utils.NewToolResultText(string(out)), &DiscussionCommentWriteOutput{Comment: output}, nil } -func markDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { +func markDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, *DiscussionCommentWriteOutput, error) { commentNodeID, err := requiredCommentNodeID(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -941,21 +979,19 @@ func markDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, return utils.NewToolResultError(err.Error()), nil, nil } - out, err := json.Marshal(struct { - DiscussionID string `json:"discussionID"` - DiscussionURL string `json:"discussionURL"` - }{ + output := &DiscussionAnswerOutput{ DiscussionID: fmt.Sprintf("%v", mutation.MarkDiscussionCommentAsAnswer.Discussion.ID), DiscussionURL: string(mutation.MarkDiscussionCommentAsAnswer.Discussion.URL), - }) + } + out, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + return utils.NewToolResultText(string(out)), &DiscussionCommentWriteOutput{Answer: output}, nil } -func unmarkDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { +func unmarkDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, *DiscussionCommentWriteOutput, error) { commentNodeID, err := requiredCommentNodeID(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -976,26 +1012,25 @@ func unmarkDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Clien return utils.NewToolResultError(err.Error()), nil, nil } - out, err := json.Marshal(struct { - DiscussionID string `json:"discussionID"` - DiscussionURL string `json:"discussionURL"` - }{ + output := &DiscussionAnswerOutput{ DiscussionID: fmt.Sprintf("%v", mutation.UnmarkDiscussionCommentAsAnswer.Discussion.ID), DiscussionURL: string(mutation.UnmarkDiscussionCommentAsAnswer.Discussion.URL), - }) + } + out, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + return utils.NewToolResultText(string(out)), &DiscussionCommentWriteOutput{Answer: output}, nil } func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ListDiscussionCategoriesInput, *DiscussionCategoriesOutput]( ToolsetMetadataDiscussions, mcp.Tool{ - Name: "list_discussion_categories", - Description: t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository or organisation."), + Name: "list_discussion_categories", + OutputSchema: discussionNotificationOutputSchema[DiscussionCategoriesOutput](), + Description: t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository or organisation."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"), ReadOnlyHint: true, @@ -1016,7 +1051,11 @@ func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.Se }, }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListDiscussionCategoriesInput) (*mcp.CallToolResult, *DiscussionCategoriesOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1090,7 +1129,20 @@ func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.Se // Discussion categories are repo-defined structural metadata // (trusted); confidentiality follows repo visibility. result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) - return result, nil, nil + items := make([]DiscussionCategoryItemOutput, 0, len(categories)) + for _, category := range categories { + items = append(items, DiscussionCategoryItemOutput{ID: category["id"], Name: category["name"]}) + } + return result, &DiscussionCategoriesOutput{ + Categories: items, + PageInfo: DiscussionPageInfoOutput{ + HasNextPage: bool(q.Repository.DiscussionCategories.PageInfo.HasNextPage), + HasPreviousPage: bool(q.Repository.DiscussionCategories.PageInfo.HasPreviousPage), + StartCursor: string(q.Repository.DiscussionCategories.PageInfo.StartCursor), + EndCursor: string(q.Repository.DiscussionCategories.PageInfo.EndCursor), + }, + TotalCount: q.Repository.DiscussionCategories.TotalCount, + }, nil }, ) } diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index a41a903d4e..be94ad777b 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -6,10 +6,10 @@ 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" - "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -571,7 +571,7 @@ func Test_GetDiscussion(t *testing.T) { expected: map[string]any{ "number": float64(1), "title": sanitizedText, - "body": sanitizedText, + "body": sanitizedContentText, "url": "https://github.com/owner/repo/discussions/1", "closed": false, "isAnswered": false, diff --git a/pkg/github/encoded_schemas_test.go b/pkg/github/encoded_schemas_test.go new file mode 100644 index 0000000000..820e019bb6 --- /dev/null +++ b/pkg/github/encoded_schemas_test.go @@ -0,0 +1,275 @@ +package github + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "slices" + "testing" + + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The baseline uses the public single-tool registration path, which does not +// install the tools/list encoder. Both paths select the same inventory first. +func schemaListServer(ctx context.Context, inv *inventory.Inventory, optimized bool, pageSize int, schemaCache *mcp.SchemaCache) *mcp.Server { + server := mcp.NewServer(&mcp.Implementation{Name: "schema-list"}, &mcp.ServerOptions{PageSize: pageSize, SchemaCache: schemaCache}) + if optimized { + inv.RegisterTools(ctx, server, nil) + } else { + for _, tool := range inv.ToolsForRegistration(ctx) { + tool.RegisterFunc(server, nil) + } + } + return server +} + +func schemaListContext(protocol string, ui bool) context.Context { + return ghcontext.WithUISupport(ghcontext.WithMCPMethodInfo(context.Background(), &ghcontext.MCPMethodInfo{ + Method: inventory.MCPMethodToolsList, + ProtocolVersion: protocol, + ClientCapabilities: &mcp.ClientCapabilities{}, + }), ui) +} + +type schemaListWire struct { + Result *mcp.ListToolsResult `json:"result"` + Error json.RawMessage `json:"error"` +} + +func schemaListPages(ctx context.Context, t *testing.T, inv *inventory.Inventory, optimized bool, transport, protocol string, pageSize int, schemaCache *mcp.SchemaCache) ([][]byte, []string) { + t.Helper() + var request func(string) []byte + switch transport { + case "stdio": + server := schemaListServer(ctx, inv, optimized, pageSize, schemaCache) + serverConn, clientConn := net.Pipe() + ss, err := server.Connect(ctx, &mcp.IOTransport{Reader: serverConn, Writer: serverConn}, nil) + require.NoError(t, err) + defer func() { _ = ss.Close() }() + defer func() { _ = clientConn.Close() }() + reader := bufio.NewReader(clientConn) + exchange := func(body string) []byte { + _, err := io.WriteString(clientConn, body+"\n") + require.NoError(t, err) + response, err := reader.ReadBytes('\n') + require.NoError(t, err) + return response + } + init := exchange(fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":%q,"capabilities":{},"clientInfo":{"name":"test","version":"1"}}}`, protocol)) + require.NotContains(t, string(init), `"error"`) + _, err = io.WriteString(clientConn, `{"jsonrpc":"2.0","method":"notifications/initialized"}`+"\n") + require.NoError(t, err) + request = exchange + case "http": + // Reuse the fixed inventory's server across pages instead of repeating + // SDK annotation validation for every stateless request. + server := schemaListServer(ctx, inv, optimized, pageSize, schemaCache) + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return server + }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) + request = func(body string) []byte { + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Mcp-Protocol-Version", protocol) + req.Header.Set("Mcp-Method", "tools/list") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + require.Contains(t, []int{http.StatusOK, http.StatusBadRequest}, rec.Code, rec.Body.String()) + return rec.Body.Bytes() + } + default: + t.Fatalf("unknown transport: %s", transport) + } + var pages [][]byte + names := make([]string, 0) + listRequest := func(id int, cursor string) string { + return fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"tools/list","params":{"cursor":%q,"_meta":{"io.modelcontextprotocol/protocolVersion":%q,"io.modelcontextprotocol/clientInfo":{"name":"test","version":"1"},"io.modelcontextprotocol/clientCapabilities":{}}}}`, id, cursor, protocol) + } + cursor := "" + for { + response := request(listRequest(2, cursor)) + pages = append(pages, response) + var wire schemaListWire + require.NoError(t, json.Unmarshal(response, &wire)) + require.Empty(t, wire.Error) + require.NotNil(t, wire.Result) + for _, tool := range wire.Result.Tools { + names = append(names, tool.Name) + } + if wire.Result.NextCursor == "" { + break + } + require.NotEqual(t, cursor, wire.Result.NextCursor) + cursor = wire.Result.NextCursor + } + bad := request(listRequest(3, "not-a-valid-cursor")) + var wire schemaListWire + require.NoError(t, json.Unmarshal(bad, &wire)) + require.NotEmpty(t, wire.Error) + pages = append(pages, bad) + return pages, names +} + +func TestEncodedSchemasWireParity(t *testing.T) { + // Share immutable resolved schemas without changing either registration + // path or the wire comparison. + schemaCache := mcp.NewSchemaCache() + type selection struct { + name string + toolsets []string + features []string + exclude []string + explicit []string + scopes []string + protocol string + ui bool + } + prototype, err := NewInventory(translations.NullTranslationHelper).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + var selections []selection + for _, toolset := range append([]inventory.ToolsetID{"all", "default"}, prototype.ToolsetIDs()...) { + for _, enabled := range []bool{false, true} { + features := []string(nil) + if enabled { + features = AllowedFeatureFlags + } + selections = append(selections, selection{ + name: fmt.Sprintf("%s/all-flags=%v", toolset, enabled), toolsets: []string{string(toolset)}, features: features, + }) + } + } + for _, feature := range AllowedFeatureFlags { + selections = append(selections, selection{name: "flag/" + feature, toolsets: []string{"all"}, features: []string{feature}}) + } + selections = append(selections, + selection{name: "insiders", toolsets: []string{"all"}, features: InsidersFeatureFlags}, + selection{name: "none", toolsets: []string{}}, + selection{name: "explicit-and-excluded", toolsets: []string{"repos"}, explicit: []string{"get_me"}, exclude: []string{"get_commit"}}, + selection{name: "scopes", toolsets: []string{"all"}, scopes: []string{"read:user"}}, + selection{name: "legacy-no-ui", toolsets: []string{"all"}, features: AllowedFeatureFlags, protocol: "2025-11-25"}, + selection{name: "apps", toolsets: []string{"all"}, features: AllowedFeatureFlags, ui: true}, + ) + for _, host := range []utils.HostType{utils.HostTypeDotcom, utils.HostTypeGHEC, utils.HostTypeGHES} { + // Reuse the same static definitions across request-time variants, just as + // both the local HTTP and remote inventory factories do. + definitions := AllTools(translations.NullTranslationHelper, WithHost(host)) + for _, readOnly := range []bool{false, true} { + for _, sel := range selections { + t.Run(fmt.Sprintf("host=%d/readonly=%v/%s", host, readOnly, sel.name), func(t *testing.T) { + protocol := sel.protocol + if protocol == "" { + protocol = inventory.ProtocolVersionMultiRoundTrip + } + ctx := schemaListContext(protocol, sel.ui) + flags := ResolveFeatureFlags(sel.features, false) + builder := inventory.NewBuilder().SetTools(definitions). + WithToolsets(sel.toolsets).WithReadOnly(readOnly). + WithTools(sel.explicit).WithExcludeTools(sel.exclude). + WithFeatureChecker(func(_ context.Context, stringFlag string) (bool, error) { + return flags[stringFlag], nil + }) + if sel.scopes != nil { + builder.WithFilter(CreateToolScopeFilter(sel.scopes)) + } + inv, err := builder.Build() + require.NoError(t, err) + wantNames := make([]string, 0) + for _, tool := range inv.ToolsForRegistration(ctx) { + wantNames = append(wantNames, tool.Tool.Name) + } + slices.Sort(wantNames) + wantNames = slices.Compact(wantNames) + for _, transport := range []string{"stdio", "http"} { + t.Run(transport, func(t *testing.T) { + before, baselineNames := schemaListPages(ctx, t, inv, false, transport, protocol, 7, schemaCache) + after, encodedNames := schemaListPages(ctx, t, inv, true, transport, protocol, 7, schemaCache) + assert.Equal(t, before, after, "complete JSON-RPC wire responses, including cursors and errors") + assert.Equal(t, wantNames, baselineNames) + assert.Equal(t, wantNames, encodedNames) + }) + } + }) + } + } + } +} + +func schemaListClient(tb testing.TB, server *mcp.Server) *mcp.ClientSession { + tb.Helper() + st, ct := mcp.NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + require.NoError(tb, err) + client := mcp.NewClient(&mcp.Implementation{Name: "benchmark"}, nil) + cs, err := client.Connect(context.Background(), ct, nil) + require.NoError(tb, err) + tb.Cleanup(func() { + _ = cs.Close() + _ = ss.Close() + }) + return cs +} + +func BenchmarkEncodedSchemas(b *testing.B) { + ctx := schemaListContext(inventory.ProtocolVersionMultiRoundTrip, false) + inv, err := NewInventory(translations.NullTranslationHelper). + WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return true, nil }). + Build() + require.NoError(b, err) + for _, optimized := range []bool{false, true} { + name := map[bool]string{false: "baseline", true: "encoded"}[optimized] + b.Run(name, func(b *testing.B) { + b.Run("ListTools", func(b *testing.B) { + cs := schemaListClient(b, schemaListServer(ctx, inv, optimized, 1000, nil)) + result, err := cs.ListTools(ctx, nil) + require.NoError(b, err) + payload, err := json.Marshal(result) + require.NoError(b, err) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if _, err := cs.ListTools(ctx, nil); err != nil { + b.Fatal(err) + } + } + b.ReportMetric(float64(len(payload)), "payload-B") + b.ReportMetric(float64(len(result.Tools)), "tools") + }) + b.Run("ConstructionAndList", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + st, ct := mcp.NewInMemoryTransports() + server := schemaListServer(ctx, inv, optimized, 1000, nil) + ss, err := server.Connect(ctx, st, nil) + if err != nil { + b.Fatal(err) + } + cs, err := mcp.NewClient(&mcp.Implementation{Name: "benchmark"}, nil).Connect(ctx, ct, nil) + if err != nil { + b.Fatal(err) + } + _, err = cs.ListTools(ctx, nil) + _ = cs.Close() + _ = ss.Close() + if err != nil { + b.Fatal(err) + } + } + }) + }) + } +} diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index 4ecd42b653..1bf679f5c9 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -1,9 +1,10 @@ package github -import "slices" +import ( + "slices" -// MCPAppsFeatureFlag is the feature flag name for MCP Apps (interactive UI forms). -const MCPAppsFeatureFlag = "remote_mcp_ui_apps" + "github.com/github/github-mcp-server/v2/pkg/inventory" +) // MCPAppsDisableFormDeferralFeatureFlag disables handing write-tool calls off // to MCP App forms while preserving MCP Apps UI metadata and result views. @@ -34,12 +35,15 @@ const FeatureFlagIssueDependencies = "issue_dependencies" // opt-in. const FeatureFlagDuplicateDetection = "duplicate_detection" +// FeatureFlagThreadResolutionReason exposes resolution reasons for Copilot review threads. +const FeatureFlagThreadResolutionReason = "thread_resolution_reason" + // AllowedFeatureFlags is the allowlist of feature flags that can be enabled -// by users via --features CLI flag or X-MCP-Features HTTP header. +// by users via --features CLI flag, X-MCP-Features HTTP header, or the +// features URL query parameter. // 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, MCPAppsDisableFormDeferralFeatureFlag, FeatureFlagCSVOutput, FeatureFlagIFCLabels, @@ -48,6 +52,7 @@ var AllowedFeatureFlags = []string{ FeatureFlagFileBlame, FeatureFlagIssueDependencies, FeatureFlagDuplicateDetection, + FeatureFlagThreadResolutionReason, } // InsidersFeatureFlags is the list of feature flags that insiders mode enables. @@ -55,7 +60,6 @@ var AllowedFeatureFlags = []string{ // This is the single source of truth for what "insiders" means in terms of // feature flag expansion. var InsidersFeatureFlags = []string{ - MCPAppsFeatureFlag, FeatureFlagCSVOutput, FeatureFlagFileBlame, FeatureFlagIssueDependencies, @@ -66,8 +70,36 @@ type FeatureFlags struct { LockdownMode bool } +func featureEnabledRule(feature string) inventory.FeatureRule { + flag := inventory.FeatureFlag(feature) + return inventory.NewFeatureRule( + []inventory.FeatureFlag{flag}, + func(featureAsBool inventory.FeatureResolver) bool { + return featureAsBool(flag) + }, + ) +} + +func featureDisabledRule(feature string) inventory.FeatureRule { + flag := inventory.FeatureFlag(feature) + return inventory.NewFeatureRule( + []inventory.FeatureFlag{flag}, + func(featureAsBool inventory.FeatureResolver) bool { + return !featureAsBool(flag) + }, + ) +} + +var ( + issuesGranularFeatureRule = featureEnabledRule(FeatureFlagIssuesGranular) + issuesConsolidatedFeatureRule = featureDisabledRule(FeatureFlagIssuesGranular) + pullRequestsGranularFeatureRule = featureEnabledRule(FeatureFlagPullRequestsGranular) + pullRequestsConsolidatedRule = featureDisabledRule(FeatureFlagPullRequestsGranular) +) + // ResolveFeatureFlags computes the effective set of enabled feature flags by: -// 1. Taking the user-supplied flags (from --features or X-MCP-Features) and +// 1. Taking the user-supplied flags (from --features or HTTP request +// configuration) 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. @@ -83,9 +115,9 @@ type FeatureFlags struct { // 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 + for _, feature := range enabledFeatures { + if slices.Contains(AllowedFeatureFlags, feature) { + effective[feature] = true } } if insidersMode { diff --git a/pkg/github/feature_flags_benchmark_test.go b/pkg/github/feature_flags_benchmark_test.go new file mode 100644 index 0000000000..8868f2fe78 --- /dev/null +++ b/pkg/github/feature_flags_benchmark_test.go @@ -0,0 +1,188 @@ +package github + +import ( + "context" + "fmt" + "sync/atomic" + "testing" + + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func BenchmarkFeatureInventory(b *testing.B) { + for _, distribution := range featureBenchmarkDistributions() { + b.Run(distribution.name, func(b *testing.B) { + b.Run("build", func(b *testing.B) { + var calls atomic.Int64 + b.ReportAllocs() + for b.Loop() { + _, err := featureBenchmarkBuilder(distribution, &calls).Build() + if err != nil { + b.Fatal(err) + } + } + b.ReportMetric(float64(calls.Load())/float64(b.N), "checks/op") + }) + + builder := featureBenchmarkBuilder(distribution, nil) + b.Run("preconstructed-builder", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, err := builder.Build(); err != nil { + b.Fatal(err) + } + } + }) + + b.Run("tools-list", func(b *testing.B) { + inv, calls := featureBenchmarkInventory(b, distribution) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = inv.ForMCPRequest(inventory.MCPMethodToolsList, "").ToolsForRegistration(context.Background()) + } + b.ReportMetric(float64(calls.Load())/float64(b.N), "checks/op") + }) + + b.Run("read-only-tools-list", func(b *testing.B) { + var calls atomic.Int64 + checker := func(_ context.Context, flag string) (bool, error) { + calls.Add(1) + return distribution.enabled["*"] || distribution.enabled[flag], nil + } + tools := AllTools(translations.NullTranslationHelper) + inv, err := inventory.NewBuilder(). + SetTools(tools). + SetResources(AllResources(translations.NullTranslationHelper)). + SetPrompts(AllPrompts(translations.NullTranslationHelper)). + WithToolsets([]string{"all"}). + WithReadOnly(true). + WithFeatureChecker(checker). + Build() + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = inv.ForMCPRequest(inventory.MCPMethodToolsList, "").ToolsForRegistration(context.Background()) + } + b.ReportMetric(float64(calls.Load())/float64(b.N), "checks/op") + }) + + b.Run("unflagged-tool-call", func(b *testing.B) { + inv, calls := featureBenchmarkInventory(b, distribution) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = inv.ForMCPRequest(inventory.MCPMethodToolsCall, "get_commit").ToolsForRegistration(context.Background()) + } + b.ReportMetric(float64(calls.Load())/float64(b.N), "checks/op") + }) + + b.Run("gated-tool-call", func(b *testing.B) { + inv, calls := featureBenchmarkInventory(b, distribution) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = inv.ForMCPRequest(inventory.MCPMethodToolsCall, "get_file_blame").ToolsForRegistration(context.Background()) + } + b.ReportMetric(float64(calls.Load())/float64(b.N), "checks/op") + }) + + b.Run("ui-tool-call", func(b *testing.B) { + inv, calls := featureBenchmarkInventory(b, distribution) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = inv.ForMCPRequest(inventory.MCPMethodToolsCall, "ui_get").ToolsForRegistration(context.Background()) + } + b.ReportMetric(float64(calls.Load())/float64(b.N), "checks/op") + }) + + b.Run("direct-handler-checks", func(b *testing.B) { + var calls atomic.Int64 + checker := func(_ context.Context, flag string) (bool, error) { + calls.Add(1) + return distribution.enabled["*"] || distribution.enabled[flag], nil + } + b.ReportAllocs() + for b.Loop() { + ctx := inventory.WithFeatureState(context.Background(), checker) + _ = inventory.ResolveFeature(ctx, checker, inventory.FeatureFlag(FeatureFlagCSVOutput)) + _ = inventory.ResolveFeature(ctx, checker, inventory.FeatureFlag(FeatureFlagCSVOutput)) + } + b.ReportMetric(float64(calls.Load())/float64(b.N), "checks/op") + }) + + b.Run("build-list-register", func(b *testing.B) { + var calls atomic.Int64 + b.ReportAllocs() + for b.Loop() { + inv, err := featureBenchmarkBuilder(distribution, &calls).Build() + if err != nil { + b.Fatal(err) + } + inv = inv.ForMCPRequest(inventory.MCPMethodToolsList, "") + server := mcp.NewServer(&mcp.Implementation{Name: "benchmark", Version: "v0"}, nil) + inv.RegisterAll(context.Background(), server, nil) + } + b.ReportMetric(float64(calls.Load())/float64(b.N), "checks/op") + }) + }) + } +} + +type featureBenchmarkDistribution struct { + name string + enabled map[string]bool +} + +func featureBenchmarkDistributions() []featureBenchmarkDistribution { + return []featureBenchmarkDistribution{ + {name: "all-false"}, + { + name: "mixed", + enabled: map[string]bool{ + FeatureFlagCSVOutput: true, + FeatureFlagFileBlame: true, + FeatureFlagIssuesGranular: true, + FeatureFlagIssueDependencies: true, + }, + }, + {name: "all-true", enabled: map[string]bool{"*": true}}, + } +} + +func featureBenchmarkBuilder(distribution featureBenchmarkDistribution, calls *atomic.Int64) *inventory.Builder { + checker := func(_ context.Context, flag string) (bool, error) { + if calls != nil { + calls.Add(1) + } + return distribution.enabled["*"] || distribution.enabled[flag], nil + } + tools := AllTools(translations.NullTranslationHelper) + for i, baseCount := 0, len(tools); len(tools) < 139; i++ { + tool := tools[i%baseCount] + tool.Tool.Name = fmt.Sprintf("%s_remote_%d", tool.Tool.Name, i) + tools = append(tools, tool) + } + return inventory.NewBuilder(). + SetTools(tools). + SetResources(AllResources(translations.NullTranslationHelper)). + SetPrompts(AllPrompts(translations.NullTranslationHelper)). + WithToolsets([]string{"all"}). + WithFeatureChecker(checker) +} + +func featureBenchmarkInventory(b *testing.B, distribution featureBenchmarkDistribution) (*inventory.Inventory, *atomic.Int64) { + b.Helper() + var calls atomic.Int64 + inv, err := featureBenchmarkBuilder(distribution, &calls).Build() + if err != nil { + b.Fatal(err) + } + return inv, &calls +} diff --git a/pkg/github/feature_flags_test.go b/pkg/github/feature_flags_test.go index 5a67b4bf21..173a8cfd12 100644 --- a/pkg/github/feature_flags_test.go +++ b/pkg/github/feature_flags_test.go @@ -5,23 +5,24 @@ import ( "encoding/json" "testing" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/jsonschema-go/jsonschema" "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" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/utils" ) // RemoteMCPEnthusiasticGreeting is a dummy test feature flag . -const RemoteMCPEnthusiasticGreeting = "remote_mcp_enthusiastic_greeting" +const RemoteMCPEnthusiasticGreeting inventory.FeatureFlag = "remote_mcp_enthusiastic_greeting" -func featureCheckerFor(enabledFlags ...string) func(context.Context, string) (bool, error) { +func featureCheckerFor(enabledFlags ...inventory.FeatureFlag) inventory.FeatureFlagChecker { enabled := make(map[string]bool, len(enabledFlags)) for _, flag := range enabledFlags { - enabled[flag] = true + enabled[string(flag)] = true } return func(_ context.Context, flagName string) (bool, error) { return enabled[flagName], nil @@ -46,7 +47,7 @@ func HelloWorldTool(t translations.TranslationHelperFunc) inventory.ServerTool { // Check feature flag to determine greeting style greeting := "Hello, world!" - if deps.IsFeatureEnabled(ctx, RemoteMCPEnthusiasticGreeting) { + if deps.IsFeatureEnabled(ctx, string(RemoteMCPEnthusiasticGreeting)) { greeting += " Welcome to the future of MCP! 🎉" } @@ -90,7 +91,7 @@ func TestHelloWorld_ConditionalBehavior_Featureflag(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - var enabledFlags []string + var enabledFlags []inventory.FeatureFlag if tt.featureFlagEnabled { enabledFlags = append(enabledFlags, RemoteMCPEnthusiasticGreeting) } @@ -148,12 +149,12 @@ func TestResolveFeatureFlags(t *testing.T) { name: "no features, no insiders", enabledFeatures: nil, expectedFlags: nil, - unexpectedFlags: []string{MCPAppsFeatureFlag}, + unexpectedFlags: []string{FeatureFlagCSVOutput}, }, { name: "explicit feature enabled", - enabledFeatures: []string{MCPAppsFeatureFlag}, - expectedFlags: []string{MCPAppsFeatureFlag}, + enabledFeatures: []string{FeatureFlagCSVOutput}, + expectedFlags: []string{FeatureFlagCSVOutput}, }, { name: "MCP Apps form deferral can be disabled directly", @@ -190,8 +191,8 @@ func TestResolveFeatureFlags(t *testing.T) { }, { name: "mix of known and unknown flags", - enabledFeatures: []string{MCPAppsFeatureFlag, "unknown_flag"}, - expectedFlags: []string{MCPAppsFeatureFlag}, + enabledFeatures: []string{FeatureFlagCSVOutput, "unknown_flag"}, + expectedFlags: []string{FeatureFlagCSVOutput}, unexpectedFlags: []string{"unknown_flag"}, }, { @@ -200,6 +201,11 @@ func TestResolveFeatureFlags(t *testing.T) { insidersMode: false, expectedFlags: []string{FeatureFlagIssuesGranular}, }, + { + name: "thread resolution reason can be directly enabled", + enabledFeatures: []string{FeatureFlagThreadResolutionReason}, + expectedFlags: []string{FeatureFlagThreadResolutionReason}, + }, { name: "insiders does not enable user-only allowed flags", enabledFeatures: nil, @@ -208,7 +214,7 @@ func TestResolveFeatureFlags(t *testing.T) { }, { name: "explicit plus insiders deduplicates", - enabledFeatures: []string{MCPAppsFeatureFlag}, + enabledFeatures: []string{FeatureFlagCSVOutput}, insidersMode: true, expectedFlags: InsidersFeatureFlags, }, @@ -227,3 +233,68 @@ func TestResolveFeatureFlags(t *testing.T) { }) } } + +func TestThreadResolutionReasonToolVariants(t *testing.T) { + tests := []struct { + name string + flags []inventory.FeatureFlag + host utils.HostType + toolName string + hasReason bool + }{ + { + name: "consolidated flag off", + toolName: "pull_request_review_write", + }, + { + name: "consolidated flag on", + flags: []inventory.FeatureFlag{FeatureFlagThreadResolutionReason}, + toolName: "pull_request_review_write", + hasReason: true, + }, + { + name: "granular flag off", + flags: []inventory.FeatureFlag{inventory.FeatureFlag(FeatureFlagPullRequestsGranular)}, + toolName: "resolve_review_thread", + }, + { + name: "granular flag on", + flags: []inventory.FeatureFlag{inventory.FeatureFlag(FeatureFlagPullRequestsGranular), FeatureFlagThreadResolutionReason}, + toolName: "resolve_review_thread", + hasReason: true, + }, + { + name: "consolidated flag on GHES", + flags: []inventory.FeatureFlag{FeatureFlagThreadResolutionReason}, + host: utils.HostTypeGHES, + toolName: "pull_request_review_write", + }, + { + name: "granular flag on GHES", + flags: []inventory.FeatureFlag{inventory.FeatureFlag(FeatureFlagPullRequestsGranular), FeatureFlagThreadResolutionReason}, + host: utils.HostTypeGHES, + toolName: "resolve_review_thread", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inv, err := NewInventory(translations.NullTranslationHelper, WithHost(tt.host)). + WithToolsets([]string{"all"}). + WithFeatureChecker(featureCheckerFor(tt.flags...)). + Build() + require.NoError(t, err) + + var matches []inventory.ServerTool + for _, tool := range inv.AvailableTools(context.Background()) { + if tool.Tool.Name == tt.toolName { + matches = append(matches, tool) + } + } + require.Len(t, matches, 1) + schema := matches[0].Tool.InputSchema.(*jsonschema.Schema) + _, hasReason := schema.Properties["resolutionReason"] + assert.Equal(t, tt.hasReason, hasReason) + }) + } +} diff --git a/pkg/github/fields_filtering_test.go b/pkg/github/fields_filtering_test.go index cd08dcef68..b54480252f 100644 --- a/pkg/github/fields_filtering_test.go +++ b/pkg/github/fields_filtering_test.go @@ -6,10 +6,10 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -20,16 +20,16 @@ import ( func mockListCommits() []*github.RepositoryCommit { return []*github.RepositoryCommit{ { - SHA: github.Ptr("abc123def456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + SHA: new("abc123def456"), + HTMLURL: new("https://github.com/owner/repo/commit/abc123def456"), Commit: &github.Commit{ - Message: github.Ptr("First commit with a reasonably long message to add bytes"), + Message: new("First commit with a reasonably long message to add bytes"), Author: &github.CommitAuthor{ - Name: github.Ptr("Test User"), - Email: github.Ptr("test@example.com"), + Name: new("Test User"), + Email: new("test@example.com"), }, }, - Author: &github.User{Login: github.Ptr("testuser")}, + Author: &github.User{Login: new("testuser")}, }, } } @@ -79,8 +79,8 @@ func mockListReleases() []*github.RepositoryRelease { { ID: 1, TagName: "v1.0.0", - Name: github.Ptr("First Release"), - Body: github.Ptr("Release notes with a reasonably long body to add bytes"), + Name: new("First Release"), + Body: new("Release notes with a reasonably long body to add bytes"), HTMLURL: "https://github.com/owner/repo/releases/tag/v1.0.0", }, } @@ -130,12 +130,12 @@ func Test_ListReleases_FieldsTelemetry(t *testing.T) { func mockListPullRequests() []*github.PullRequest { return []*github.PullRequest{ { - Number: github.Ptr(42), - Title: github.Ptr("First PR"), - Body: github.Ptr("PR body with a reasonably long description to add bytes"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - User: &github.User{Login: github.Ptr("user1")}, + Number: new(42), + Title: new("First PR"), + Body: new("PR body with a reasonably long description to add bytes"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), + User: &github.User{Login: new("user1")}, }, } } @@ -187,16 +187,16 @@ func Test_ListPullRequests_FieldsTelemetry(t *testing.T) { // does not attempt the follow-up GraphQL field-values enrichment. func mockIssueSearchResult() *github.IssuesSearchResult { return &github.IssuesSearchResult{ - Total: github.Ptr(1), - IncompleteResults: github.Ptr(false), + Total: new(1), + IncompleteResults: new(false), Issues: []*github.Issue{ { - Number: github.Ptr(42), - Title: github.Ptr("A result"), - Body: github.Ptr("Body with a reasonably long description to add bytes"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - User: &github.User{Login: github.Ptr("user1")}, + Number: new(42), + Title: new("A result"), + Body: new("Body with a reasonably long description to add bytes"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), + User: &github.User{Login: new("user1")}, }, }, } diff --git a/pkg/github/fields_telemetry_test.go b/pkg/github/fields_telemetry_test.go index 14e898bf29..63e4da9326 100644 --- a/pkg/github/fields_telemetry_test.go +++ b/pkg/github/fields_telemetry_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/pkg/observability" - "github.com/github/github-mcp-server/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/observability" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/find_duplicate.go b/pkg/github/find_duplicate.go index 65cb6f2002..b276d608da 100644 --- a/pkg/github/find_duplicate.go +++ b/pkg/github/find_duplicate.go @@ -8,12 +8,12 @@ import ( "net/url" "strconv" - 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" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -33,15 +33,6 @@ type rankedSimilarIssue struct { LikelyDuplicate bool `json:"likely_duplicate"` } -// duplicateCandidate is the trimmed output for a ranked duplicate candidate, -// carrying only what an agent needs to explain and act on it. -type duplicateCandidate struct { - Issue MinimalIssueRef `json:"issue"` - Score *float64 `json:"score"` - Confidence string `json:"confidence"` - LikelyDuplicate bool `json:"likely_duplicate"` -} - // FindDuplicate creates a read-only tool that returns ranked duplicate // candidates for an existing issue. It is a separate, feature-flagged tool so // duplicate detection is only advertised when explicitly opted in, keeping the @@ -71,8 +62,11 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "issue_number"}, } WithPagination(schema) - - st := NewTool( + schema.Properties["page"].Minimum = new(0.0) + schema.Properties["page"].Description = "Page number for pagination (min 0). Zero is forwarded for the GitHub API default." + schema.Properties["perPage"].Minimum = new(0.0) + schema.Properties["perPage"].Description = "Results per page for pagination (min 0, max 100). Zero is forwarded for the GitHub API default." + st := NewToolWithSchemaOptions[FindDuplicateInput, []DuplicateCandidate]( ToolsetMetadataIssues, mcp.Tool{ Name: "find_duplicate", @@ -81,44 +75,28 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_FIND_DUPLICATE_USER_TITLE", "Find duplicate issues"), ReadOnlyHint: true, }, - InputSchema: schema, + InputSchema: schema, + OutputSchema: findDuplicateOutputSchema(), }, scopes.PublicRead(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 { + inventory.TypedSchemaOptions{ValidationInputSchema: issuePaginationValidationSchema(schema), PreserveHandlerContent: true}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input FindDuplicateInput) (*mcp.CallToolResult, []DuplicateCandidate, error) { + owner, repo, issueNumber := input.Owner, input.Repo, input.IssueNumber + if err := validateIssueCoordinate(owner, repo, issueNumber); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // Build the query preserving whether each optional value was supplied // so unset parameters fall back to the API's own defaults. query := url.Values{} - if threshold, ok, err := OptionalParamOK[float64](args, "confidence_threshold"); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } else if ok { - query.Set("threshold", strconv.FormatFloat(threshold, 'g', -1, 64)) + if input.ConfidenceThreshold != nil { + query.Set("threshold", strconv.FormatFloat(*input.ConfidenceThreshold, 'g', -1, 64)) } - if _, ok := args["perPage"]; ok { - perPage, err := OptionalIntParam(args, "perPage") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - query.Set("per_page", strconv.Itoa(perPage)) + if input.PerPage != nil { + query.Set("per_page", strconv.Itoa(*input.PerPage)) } - if _, ok := args["page"]; ok { - page, err := OptionalIntParam(args, "page") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - query.Set("page", strconv.Itoa(page)) + if input.Page != nil { + query.Set("page", strconv.Itoa(*input.Page)) } client, err := deps.GetClient(ctx) @@ -143,7 +121,8 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { } defer func() { _ = resp.Body.Close() }() - candidates := make([]duplicateCandidate, 0, len(results)) + candidates := make([]DuplicateCandidate, 0, len(results)) + legacyCandidates := make([]LegacyDuplicateCandidate, 0, len(results)) for _, res := range results { // A bare issue (no ranking metadata) means ranked duplicate // detection is not enabled for this caller; fail clearly rather @@ -151,23 +130,33 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { if res.Confidence == "" || res.Issue == nil { return utils.NewToolResultError("ranked duplicate detection is unavailable: the semantic-similarity endpoint returned issues without ranking metadata (the server-side duplicate-ranking feature is not enabled for this caller or repository)"), nil, nil } - candidates = append(candidates, duplicateCandidate{ + ref := newMinimalIssueRef( + res.Issue.Number, + res.Issue.Title, + res.Issue.State, + res.Issue.HTMLURL, + "", + ) + candidates = append(candidates, DuplicateCandidate{ // Candidates are always scoped to the requested repository, so the // ref's repository field is left empty as it was before. - Issue: newMinimalIssueRef( - res.Issue.Number, - res.Issue.Title, - res.Issue.State, - res.Issue.HTMLURL, - "", - ), + Issue: MinimalDuplicateIssue{ + Number: ref.Number, Title: ref.Title, + State: DuplicateIssueState(ref.State), HTMLURL: ref.URL, + }, + Score: res.Score, + Confidence: DuplicateConfidence(res.Confidence), + LikelyDuplicate: res.LikelyDuplicate, + }) + legacyCandidates = append(legacyCandidates, LegacyDuplicateCandidate{ + Issue: ref, Score: res.Score, - Confidence: res.Confidence, + Confidence: DuplicateConfidence(res.Confidence), LikelyDuplicate: res.LikelyDuplicate, }) } - r, err := json.Marshal(candidates) + r, err := json.Marshal(legacyCandidates) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal duplicate candidates", err), nil, nil } @@ -176,8 +165,9 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { // repository, so classify the result like issue_read. result := utils.NewToolResultText(string(r)) result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoUserContent) - return result, nil, nil - }) - st.FeatureFlagEnable = FeatureFlagDuplicateDetection + return result, candidates, nil + }, normalizeIssueStrings([]string{"owner", "repo"}, nil), + normalizeIssueIntegers([]string{"issue_number"}, []string{"page", "perPage"}), normalizeDuplicateThreshold) + st.FeatureRule = featureEnabledRule(FeatureFlagDuplicateDetection) return st } diff --git a/pkg/github/find_duplicate_test.go b/pkg/github/find_duplicate_test.go index 9e20d958b4..e6c2dc233e 100644 --- a/pkg/github/find_duplicate_test.go +++ b/pkg/github/find_duplicate_test.go @@ -3,12 +3,14 @@ package github import ( "context" "encoding/json" + "maps" "net/http" "net/url" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -20,8 +22,8 @@ func Test_FindDuplicate(t *testing.T) { // Verify tool definition once (flag-gated variant snap). serverTool := FindDuplicate(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagDuplicateDetection, tool)) - require.Equal(t, FeatureFlagDuplicateDetection, serverTool.FeatureFlagEnable) + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+string(FeatureFlagDuplicateDetection), tool)) + require.Equal(t, []inventory.FeatureFlag{FeatureFlagDuplicateDetection}, serverTool.FeatureRule.Features()) assert.Equal(t, "find_duplicate", tool.Name) assert.NotEmpty(t, tool.Description) @@ -38,6 +40,12 @@ func Test_FindDuplicate(t *testing.T) { assert.Contains(t, schema.Properties, "page") assert.Contains(t, schema.Properties, "perPage") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "issue_number"}) + + assert.Equal(t, 0.0, *schema.Properties["page"].Minimum) + assert.Equal(t, 0.0, *schema.Properties["perPage"].Minimum) + assert.Equal(t, 100.0, *schema.Properties["perPage"].Maximum) + assert.Equal(t, "Page number for pagination (min 0). Zero is forwarded for the GitHub API default.", schema.Properties["page"].Description) + assert.Equal(t, "Results per page for pagination (min 0, max 100). Zero is forwarded for the GitHub API default.", schema.Properties["perPage"].Description) } func Test_FindDuplicate_RankedResults(t *testing.T) { @@ -103,11 +111,11 @@ func Test_FindDuplicate_RankedResults(t *testing.T) { assert.Equal(t, "1", capturedURL.Query().Get("page")) text := getTextResult(t, result) - var candidates []duplicateCandidate + var candidates []LegacyDuplicateCandidate require.NoError(t, json.Unmarshal([]byte(text.Text), &candidates)) require.Len(t, candidates, 2) - assert.Equal(t, "high", candidates[0].Confidence) + assert.Equal(t, "high", string(candidates[0].Confidence)) assert.True(t, candidates[0].LikelyDuplicate) require.NotNil(t, candidates[0].Score) assert.InDelta(t, 0.95, *candidates[0].Score, 0.0001) @@ -118,10 +126,90 @@ func Test_FindDuplicate_RankedResults(t *testing.T) { // A null score must decode successfully. assert.Nil(t, candidates[1].Score) - assert.Equal(t, "low", candidates[1].Confidence) + assert.Equal(t, "low", string(candidates[1].Confidence)) assert.False(t, candidates[1].LikelyDuplicate) } +func Test_FindDuplicate_ZeroPaginationIsForwarded(t *testing.T) { + serverTool := FindDuplicate(translations.NullTranslationHelper) + var capturedRawQuery string + client := mustNewGHClient(t, NewMockedHTTPClient(WithRequestMatchHandler( + endpointSemanticallySimilar, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedRawQuery = r.URL.RawQuery + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("[]")) + }), + ))) + deps := BaseDeps{Client: client} + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "confidence_threshold": float64(0), + "page": float64(0), + "perPage": float64(0), + }) + result, err := serverTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, "page=0&per_page=0&threshold=0", capturedRawQuery) + + advertised := serverTool.Tool.InputSchema.(*jsonschema.Schema) + validation, err := advertised.Resolve(nil) + require.NoError(t, err) + require.NoError(t, validation.Validate(map[string]any{ + "owner": "owner", "repo": "repo", "issue_number": float64(123), + "page": float64(0), + "perPage": float64(0), + })) +} + +func Test_FindDuplicate_PaginationContracts(t *testing.T) { + serverTool := FindDuplicate(translations.NullTranslationHelper) + advertised := serverTool.Tool.InputSchema.(*jsonschema.Schema) + for name, schema := range map[string]*jsonschema.Schema{ + "advertised": advertised, + "runtime": issuePaginationValidationSchema(advertised), + } { + t.Run(name, func(t *testing.T) { + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + for _, tc := range []struct { + name string + params map[string]any + valid bool + }{ + {"omitted", map[string]any{}, true}, + {"zero", map[string]any{"page": float64(0), "perPage": float64(0)}, true}, + {"positive", map[string]any{"page": float64(1), "perPage": float64(30)}, true}, + {"maximum perPage", map[string]any{"page": float64(101), "perPage": float64(100)}, true}, + {"negative page", map[string]any{"page": float64(-1)}, false}, + {"negative perPage", map[string]any{"perPage": float64(-1)}, false}, + {"excessive perPage", map[string]any{"perPage": float64(101)}, false}, + {"invalid page type", map[string]any{"page": "invalid"}, false}, + {"invalid perPage type", map[string]any{"perPage": "invalid"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + args := map[string]any{"owner": "owner", "repo": "repo", "issue_number": float64(123)} + maps.Copy(args, tc.params) + if tc.valid { + assert.NoError(t, resolved.Validate(args)) + } else { + assert.Error(t, resolved.Validate(args)) + } + }) + } + for _, field := range schema.Required { + args := map[string]any{"owner": "owner", "repo": "repo", "issue_number": float64(123)} + delete(args, field) + assert.Error(t, resolved.Validate(args), field) + } + }) + } +} + // Test_FindDuplicate_SanitizesIssueTitle asserts that candidate issue titles, which are // user-authored content from an arbitrary repository, are sanitized before being returned. // Without this the tool would forward hidden-instruction payloads straight to the model. @@ -161,11 +249,10 @@ func Test_FindDuplicate_SanitizesIssueTitle(t *testing.T) { require.False(t, result.IsError, "expected result to not be an error") text := getTextResult(t, result) - var candidates []duplicateCandidate + var candidates []DuplicateCandidate require.NoError(t, json.Unmarshal([]byte(text.Text), &candidates)) require.Len(t, candidates, 1) assert.Equal(t, sanitizedText, candidates[0].Issue.Title) - assert.NotContains(t, text.Text, "can't \"quote\" AT&T\u200B"), + Body: new("This is **Markdown**\u200B"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/42"), User: &github.User{ - Login: github.Ptr("testuser"), + Login: new("testuser"), }, Labels: []*github.Label{ { Name: "enhancement", Color: "84b6eb", - Description: github.Ptr("New feature or request"), + Description: new("New feature or request"), }, }, } @@ -5864,8 +5970,8 @@ func Test_AddSubIssue(t *testing.T) { 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, "can't \"quote\" AT&T", *returnedIssue.Title) + assert.Equal(t, "This is **Markdown**", *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) @@ -5892,33 +5998,33 @@ func Test_GetSubIssues(t *testing.T) { // Setup mock sub-issues for success case mockSubIssues := []*github.Issue{ { - Number: github.Ptr(123), - Title: github.Ptr("Sub-issue 1"), - Body: github.Ptr("This is the first sub-issue"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + Number: new(123), + Title: new("can't \"quote\" AT&T\u200B"), + Body: new("This is **Markdown**\u200B"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/123"), User: &github.User{ - Login: github.Ptr("user1"), + Login: new("user1"), }, Labels: []*github.Label{ { Name: "bug", Color: "d73a4a", - Description: github.Ptr("Something isn't working"), + Description: new("Something isn't working"), }, }, }, { - Number: github.Ptr(124), - Title: github.Ptr("Sub-issue 2"), - Body: github.Ptr("This is the second sub-issue"), - State: github.Ptr("closed"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), + Number: new(124), + Title: new("Sub-issue 2"), + Body: new("This is the second sub-issue"), + State: new("closed"), + HTMLURL: new("https://github.com/owner/repo/issues/124"), User: &github.User{ - Login: github.Ptr("user2"), + Login: new("user2"), }, Assignees: []*github.User{ - {Login: github.Ptr("assignee1")}, + {Login: new("assignee1")}, }, }, } @@ -6093,12 +6199,17 @@ func Test_GetSubIssues(t *testing.T) { for i, subIssue := range returnedSubIssues { if i < len(tc.expectedSubIssues) { assert.Equal(t, *tc.expectedSubIssues[i].Number, *subIssue.Number) - assert.Equal(t, *tc.expectedSubIssues[i].Title, *subIssue.Title) + if i == 0 { + assert.Equal(t, "can't \"quote\" AT&T", *subIssue.Title) + assert.Equal(t, "This is **Markdown**", *subIssue.Body) + } else { + assert.Equal(t, *tc.expectedSubIssues[i].Title, *subIssue.Title) + } assert.Equal(t, *tc.expectedSubIssues[i].State, *subIssue.State) assert.Equal(t, *tc.expectedSubIssues[i].HTMLURL, *subIssue.HTMLURL) assert.Equal(t, *tc.expectedSubIssues[i].User.Login, *subIssue.User.Login) - if tc.expectedSubIssues[i].Body != nil { + if i != 0 && tc.expectedSubIssues[i].Body != nil { assert.Equal(t, *tc.expectedSubIssues[i].Body, *subIssue.Body) } } @@ -6210,17 +6321,17 @@ func TestAddIssueCommentHandler(t *testing.T) { serverTool := AddIssueComment(translations.NullTranslationHelper) mockComment := &github.IssueComment{ - ID: github.Ptr(int64(456)), - Body: github.Ptr("This is a comment"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42#issuecomment-456"), + ID: new(int64(456)), + Body: new("This is a comment"), + HTMLURL: new("https://github.com/owner/repo/issues/42#issuecomment-456"), } mockReaction := &github.Reaction{ - ID: github.Ptr(int64(789)), - Content: github.Ptr("heart"), + ID: new(int64(789)), + Content: new("heart"), } mockIssueComment := &github.IssueComment{ - ID: github.Ptr(int64(999)), - IssueURL: github.Ptr("https://api.github.com/repos/owner/repo/issues/42"), + ID: new(int64(999)), + IssueURL: new("https://api.github.com/repos/owner/repo/issues/42"), } commentCreatedAfterReactionFailure := &atomic.Bool{} @@ -6235,7 +6346,9 @@ func TestAddIssueCommentHandler(t *testing.T) { { name: "successful comment on issue", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockComment), + PostReposIssuesCommentsByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ + "body": "This is a comment", + }).andThen(mockResponse(t, http.StatusCreated, mockComment)), }), requestArgs: map[string]any{ "owner": "owner", @@ -6274,8 +6387,8 @@ func TestAddIssueCommentHandler(t *testing.T) { name: "issue comment reaction requires matching issue_number", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposIssuesCommentByOwnerByRepoByCommentID: mockResponse(t, http.StatusOK, &github.IssueComment{ - ID: github.Ptr(int64(999)), - IssueURL: github.Ptr("https://api.github.com/repos/owner/repo/issues/43"), + ID: new(int64(999)), + IssueURL: new("https://api.github.com/repos/owner/repo/issues/43"), }), }), requestArgs: map[string]any{ @@ -6528,6 +6641,195 @@ func TestAddIssueCommentHandler(t *testing.T) { } } +func TestUpdateIssueCommentSchema(t *testing.T) { + t.Parallel() + + tool := UpdateIssueComment(translations.NullTranslationHelper).Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "update_issue_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, "comment_id") + assert.Contains(t, schema.Properties, "body") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "comment_id", "body"}) + + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + + baseArgs := map[string]any{ + "owner": "owner", + "repo": "repo", + "comment_id": 456, + "body": "Updated comment", + } + tests := []struct { + name string + args map[string]any + isValid bool + }{ + { + name: "valid arguments", + args: map[string]any{}, + isValid: true, + }, + { + name: "missing required body", + args: map[string]any{"body": nil}, + isValid: false, + }, + { + name: "empty body", + args: map[string]any{"body": ""}, + isValid: false, + }, + { + name: "zero comment ID", + args: map[string]any{"comment_id": 0}, + isValid: false, + }, + { + name: "fractional comment ID", + args: map[string]any{"comment_id": 1.5}, + isValid: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + args := maps.Clone(baseArgs) + maps.Copy(args, tc.args) + err := resolved.Validate(args) + if tc.isValid { + require.NoError(t, err) + return + } + require.Error(t, err) + }) + } +} + +func TestUpdateIssueCommentHandler(t *testing.T) { + t.Parallel() + + updatedComment := &github.IssueComment{ + ID: new(int64(456)), + Body: new("Updated comment"), + HTMLURL: new("https://github.com/owner/repo/issues/42#issuecomment-456"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful update", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesCommentByOwnerByRepoByCommentID: expectRequestBody(t, map[string]any{ + "body": "Updated comment", + }).andThen(mockResponse(t, http.StatusOK, updatedComment)), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "comment_id": float64(456), + "body": "Updated comment", + }, + }, + { + name: "missing body", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "comment_id": float64(456), + }, + expectToolError: true, + expectedToolErrMsg: "missing required parameter: body", + }, + { + name: "empty body", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "comment_id": float64(456), + "body": "", + }, + expectToolError: true, + expectedToolErrMsg: "body cannot be empty when provided", + }, + { + name: "negative comment ID", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "comment_id": float64(-1), + "body": "Updated comment", + }, + expectToolError: true, + expectedToolErrMsg: "comment_id must be greater than 0", + }, + { + name: "fractional comment ID", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "comment_id": float64(1.5), + "body": "Updated comment", + }, + expectToolError: true, + expectedToolErrMsg: "parameter comment_id is not a valid number", + }, + { + name: "API error", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesCommentByOwnerByRepoByCommentID: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "comment_id": float64(456), + "body": "Updated comment", + }, + expectToolError: true, + expectedToolErrMsg: "failed to update issue comment", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + serverTool := UpdateIssueComment(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectToolError { + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.expectedToolErrMsg) + return + } + + require.False(t, result.IsError) + var response MinimalResponse + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, "456", response.ID) + assert.Equal(t, "https://github.com/owner/repo/issues/42#issuecomment-456", response.URL) + }) + } +} + func Test_RemoveSubIssue(t *testing.T) { // Verify tool definition once serverTool := SubIssueWrite(translations.NullTranslationHelper) @@ -6545,19 +6847,19 @@ func Test_RemoveSubIssue(t *testing.T) { // Setup mock issue for success case (matches GitHub API response format - the updated parent issue) mockIssue := &github.Issue{ - Number: github.Ptr(42), - Title: github.Ptr("Parent Issue"), - Body: github.Ptr("This is the parent issue after sub-issue removal"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + Number: new(42), + Title: new("can't \"quote\" AT&T\u200B"), + Body: new("This is **Markdown**\u200B"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/42"), User: &github.User{ - Login: github.Ptr("testuser"), + Login: new("testuser"), }, Labels: []*github.Label{ { Name: "enhancement", Color: "84b6eb", - Description: github.Ptr("New feature or request"), + Description: new("New feature or request"), }, }, } @@ -6725,8 +7027,8 @@ func Test_RemoveSubIssue(t *testing.T) { 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, "can't \"quote\" AT&T", *returnedIssue.Title) + assert.Equal(t, "This is **Markdown**", *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) @@ -6753,19 +7055,19 @@ func Test_ReprioritizeSubIssue(t *testing.T) { // Setup mock issue for success case (matches GitHub API response format - the updated parent issue) mockIssue := &github.Issue{ - Number: github.Ptr(42), - Title: github.Ptr("Parent Issue"), - Body: github.Ptr("This is the parent issue with reprioritized sub-issues"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + Number: new(42), + Title: new("can't \"quote\" AT&T\u200B"), + Body: new("This is **Markdown**\u200B"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/42"), User: &github.User{ - Login: github.Ptr("testuser"), + Login: new("testuser"), }, Labels: []*github.Label{ { Name: "enhancement", Color: "84b6eb", - Description: github.Ptr("New feature or request"), + Description: new("New feature or request"), }, }, } @@ -6985,8 +7287,8 @@ func Test_ReprioritizeSubIssue(t *testing.T) { 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, "can't \"quote\" AT&T", *returnedIssue.Title) + assert.Equal(t, "This is **Markdown**", *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) @@ -7008,16 +7310,16 @@ func Test_ListIssueTypes(t *testing.T) { // Setup mock issue types for success case mockIssueTypes := []*github.IssueType{ { - ID: github.Ptr(int64(1)), - Name: github.Ptr("bug"), - Description: github.Ptr("Something isn't working"), - Color: github.Ptr("d73a4a"), + ID: new(int64(1)), + Name: new("bug"), + Description: new("Something isn't working"), + Color: new("d73a4a"), }, { - ID: github.Ptr(int64(2)), - Name: github.Ptr("feature"), - Description: github.Ptr("New feature or enhancement"), - Color: github.Ptr("a2eeef"), + ID: new(int64(2)), + Name: new("feature"), + Description: new("New feature or enhancement"), + Color: new("a2eeef"), }, } diff --git a/pkg/github/labels.go b/pkg/github/labels.go index 4be66a7c7d..70b5c5d8db 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -6,12 +6,12 @@ import ( "fmt" "strings" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -24,6 +24,10 @@ const labelOrderFieldIssueCount githubv4.LabelOrderField = "ISSUE_COUNT" // GetLabel retrieves a specific label by name from a GitHub repository func GetLabel(t translations.TranslationHelperFunc) inventory.ServerTool { + return typedGovernanceTool(getLabelLegacy(t), labelOutputSchema(), decodeGovernanceJSON[MinimalLabel]) +} + +func getLabelLegacy(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataIssues, mcp.Tool{ @@ -130,6 +134,10 @@ func GetLabelForLabelsToolset(t translations.TranslationHelperFunc) inventory.Se // ListLabels lists labels from a repository func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { + return typedGovernanceTool(listLabelsLegacy(t), listLabelsOutputSchema(), decodeGovernanceJSON[ListLabelsOutput]) +} + +func listLabelsLegacy(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetLabels, mcp.Tool{ @@ -228,6 +236,12 @@ func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { // LabelWrite handles create, update, and delete operations for GitHub labels func LabelWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + return typedGovernanceTool(labelWriteLegacy(t), labelWriteOutputSchema(), func(raw []byte, args map[string]json.RawMessage) (LabelWriteOutput, error) { + return LabelWriteOutput{Method: strings.ToLower(governanceArgument(args, "method")), Message: string(raw)}, nil + }) +} + +func labelWriteLegacy(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetLabels, mcp.Tool{ @@ -236,7 +250,7 @@ func LabelWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LABEL_WRITE_TITLE", "Write operations on repository labels"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), + DestructiveHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", diff --git a/pkg/github/labels_test.go b/pkg/github/labels_test.go index b030c9cab7..a527664a81 100644 --- a/pkg/github/labels_test.go +++ b/pkg/github/labels_test.go @@ -5,9 +5,9 @@ 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" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/lockdown.go b/pkg/github/lockdown.go index 1d3a687028..b1a1b006ea 100644 --- a/pkg/github/lockdown.go +++ b/pkg/github/lockdown.go @@ -6,8 +6,8 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/github/github-mcp-server/pkg/lockdown" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/lockdown" + "github.com/github/github-mcp-server/v2/pkg/utils" ) // Restriction messages returned when lockdown mode withholds content from a read tool. diff --git a/pkg/github/method_output_schema.go b/pkg/github/method_output_schema.go new file mode 100644 index 0000000000..c6b99cc458 --- /dev/null +++ b/pkg/github/method_output_schema.go @@ -0,0 +1,26 @@ +package github + +import ( + "slices" + + "github.com/google/jsonschema-go/jsonschema" +) + +func constrainMethodOutput(schema *jsonschema.Schema, payloadByMethod map[string]string, optionalPayloads ...string) { + schema.Required = []string{"method"} + for _, method := range schema.Properties["method"].Enum { + payload := payloadByMethod[method.(string)] + variant := &jsonschema.Schema{Properties: map[string]*jsonschema.Schema{ + "method": {Enum: []any{method}}, + }} + if !slices.Contains(optionalPayloads, payload) { + variant.Required = []string{payload} + } + for _, other := range payloadByMethod { + if other != payload { + variant.Properties[other] = forbiddenIssueVariantProperty() + } + } + schema.OneOf = append(schema.OneOf, variant) + } +} diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 675ec050bc..b005223cd3 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -9,10 +9,10 @@ import ( "strings" "time" - "github.com/google/go-github/v89/github" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/github/github-mcp-server/pkg/sanitize" + "github.com/github/github-mcp-server/v2/pkg/sanitize" ) // codeSearchItemFieldEnum lists the selectable fields for search_code result @@ -204,7 +204,7 @@ type MinimalDiscussionComment struct { func newMinimalDiscussionComment(id string, body string, isAnswer bool) MinimalDiscussionComment { return MinimalDiscussionComment{ ID: id, - Body: sanitize.Sanitize(body), + Body: sanitize.Content(body), IsAnswer: isAnswer, } } @@ -622,7 +622,7 @@ type MinimalPullRequestRef struct { func newMinimalPullRequestRef(number int, title, state, url, repository string) MinimalPullRequestRef { return MinimalPullRequestRef{ Number: number, - Title: sanitize.Sanitize(title), + Title: sanitize.PlainText(title), State: state, URL: url, Repository: repository, @@ -646,7 +646,7 @@ type MinimalIssueRef struct { func newMinimalIssueRef(number int, title, state, url, repository string) MinimalIssueRef { return MinimalIssueRef{ Number: number, - Title: sanitize.Sanitize(title), + Title: sanitize.PlainText(title), State: state, URL: url, Repository: repository, @@ -797,7 +797,7 @@ func convertToMinimalPullRequestReview(review *github.PullRequestReview) Minimal m := MinimalPullRequestReview{ ID: review.GetID(), State: review.GetState(), - Body: sanitize.Sanitize(review.GetBody()), + Body: sanitize.Content(review.GetBody()), HTMLURL: review.GetHTMLURL(), User: convertToMinimalUser(review.GetUser()), CommitID: review.GetCommitID(), @@ -814,8 +814,8 @@ func convertToMinimalPullRequestReview(review *github.PullRequestReview) Minimal func convertToMinimalIssue(issue *github.Issue) MinimalIssue { m := MinimalIssue{ Number: issue.GetNumber(), - Title: sanitize.Sanitize(issue.GetTitle()), - Body: sanitize.Sanitize(issue.GetBody()), + Title: sanitize.PlainText(issue.GetTitle()), + Body: sanitize.Content(issue.GetBody()), State: issue.GetState(), StateReason: issue.GetStateReason(), Draft: issue.GetDraft(), @@ -925,8 +925,8 @@ func fragmentToMinimalIssue(fragment IssueFragment) MinimalIssue { func fragmentWithoutFieldValuesToMinimalIssue(fragment issueFragmentWithoutFieldValues) MinimalIssue { m := MinimalIssue{ Number: int(fragment.Number), - Title: sanitize.Sanitize(string(fragment.Title)), - Body: sanitize.Sanitize(string(fragment.Body)), + Title: sanitize.PlainText(string(fragment.Title)), + Body: sanitize.Content(string(fragment.Body)), State: string(fragment.State), Comments: int(fragment.Comments.TotalCount), CreatedAt: fragment.CreatedAt.Format(time.RFC3339), @@ -1015,7 +1015,7 @@ func convertToMinimalIssuesResponseWithoutFieldValues(fragment issueQueryFragmen func convertToMinimalIssueComment(comment *github.IssueComment) MinimalIssueComment { m := MinimalIssueComment{ ID: comment.GetID(), - Body: sanitize.Sanitize(comment.GetBody()), + Body: sanitize.Content(comment.GetBody()), HTMLURL: comment.GetHTMLURL(), User: convertToMinimalUser(comment.GetUser()), AuthorAssociation: comment.GetAuthorAssociation(), @@ -1064,7 +1064,7 @@ func convertToMinimalFileContentResponse(resp *github.RepositoryContentResponse) m.Commit = &MinimalFileCommit{ SHA: resp.Commit.GetSHA(), - Message: sanitize.Sanitize(resp.Commit.GetMessage()), + Message: sanitize.Content(resp.Commit.GetMessage()), HTMLURL: resp.Commit.GetHTMLURL(), } @@ -1084,8 +1084,8 @@ func convertToMinimalFileContentResponse(resp *github.RepositoryContentResponse) func convertToMinimalPullRequest(pr *github.PullRequest) MinimalPullRequest { m := MinimalPullRequest{ Number: pr.GetNumber(), - Title: sanitize.Sanitize(pr.GetTitle()), - Body: sanitize.Sanitize(pr.GetBody()), + Title: sanitize.PlainText(pr.GetTitle()), + Body: sanitize.Content(pr.GetBody()), State: pr.GetState(), Draft: pr.GetDraft(), Merged: pr.GetMerged(), @@ -1211,19 +1211,19 @@ func convertToMinimalProject(fullProject *github.ProjectV2) *MinimalProject { } return &MinimalProject{ - ID: github.Ptr(fullProject.GetID()), - NodeID: github.Ptr(fullProject.GetNodeID()), + ID: new(fullProject.GetID()), + NodeID: new(fullProject.GetNodeID()), Owner: convertToMinimalUser(fullProject.GetOwner()), Creator: convertToMinimalUser(fullProject.GetCreator()), - Title: github.Ptr(fullProject.GetTitle()), - Description: github.Ptr(fullProject.GetDescription()), - Public: github.Ptr(fullProject.GetPublic()), - ClosedAt: github.Ptr(fullProject.GetClosedAt()), - CreatedAt: github.Ptr(fullProject.GetCreatedAt()), - UpdatedAt: github.Ptr(fullProject.GetUpdatedAt()), - DeletedAt: github.Ptr(fullProject.GetDeletedAt()), - Number: github.Ptr(fullProject.GetNumber()), - ShortDescription: github.Ptr(fullProject.GetShortDescription()), + Title: new(fullProject.GetTitle()), + Description: new(fullProject.GetDescription()), + Public: new(fullProject.GetPublic()), + ClosedAt: new(fullProject.GetClosedAt()), + CreatedAt: new(fullProject.GetCreatedAt()), + UpdatedAt: new(fullProject.GetUpdatedAt()), + DeletedAt: new(fullProject.GetDeletedAt()), + Number: new(fullProject.GetNumber()), + ShortDescription: new(fullProject.GetShortDescription()), DeletedBy: convertToMinimalUser(fullProject.GetDeletedBy()), } } @@ -1279,7 +1279,7 @@ func convertIssueToMinimalProjectItemContent(issue *github.Issue) *MinimalProjec ID: issue.GetID(), NodeID: issue.GetNodeID(), Number: issue.GetNumber(), - Title: sanitize.Sanitize(issue.GetTitle()), + Title: sanitize.PlainText(issue.GetTitle()), State: issue.GetState(), StateReason: issue.GetStateReason(), HTMLURL: issue.GetHTMLURL(), @@ -1316,7 +1316,7 @@ func convertPullRequestToMinimalProjectItemContent(pr *github.PullRequest) *Mini ID: pr.GetID(), NodeID: pr.GetNodeID(), Number: pr.GetNumber(), - Title: sanitize.Sanitize(pr.GetTitle()), + Title: sanitize.PlainText(pr.GetTitle()), State: pr.GetState(), HTMLURL: pr.GetHTMLURL(), Repository: pullRequestRepositoryFullName(pr), @@ -1353,7 +1353,7 @@ func convertDraftIssueToMinimalProjectItemContent(draftIssue *github.ProjectV2Dr m := &MinimalProjectItemContent{ ID: draftIssue.GetID(), NodeID: draftIssue.GetNodeID(), - Title: sanitize.Sanitize(draftIssue.GetTitle()), + Title: sanitize.PlainText(draftIssue.GetTitle()), CreatedAt: formatProjectTimestamp(draftIssue.CreatedAt), UpdatedAt: formatProjectTimestamp(draftIssue.UpdatedAt), } @@ -1612,7 +1612,7 @@ func minimalProjectPullRequestRefFromPullRequest(pr *github.PullRequest) minimal } return minimalProjectPullRequestRef{ Number: pr.GetNumber(), - Title: sanitize.Sanitize(pr.GetTitle()), + Title: sanitize.PlainText(pr.GetTitle()), State: pr.GetState(), HTMLURL: pr.GetHTMLURL(), Repository: pullRequestRepositoryFullName(pr), @@ -1634,7 +1634,7 @@ func minimalProjectPullRequestRefFromMap(value map[string]any) minimalProjectPul return minimalProjectPullRequestRef{ Number: intFromAny(value["number"]), - Title: sanitize.Sanitize(stringFromMap(value, "title")), + Title: sanitize.PlainText(stringFromMap(value, "title")), State: stringFromMap(value, "state"), HTMLURL: htmlURL, Repository: repository, @@ -1794,7 +1794,7 @@ func newMinimalCommitFromCore(sha, htmlURL string, commit *github.Commit, author if commit != nil { minimalCommit.Commit = &MinimalCommitInfo{ - Message: sanitize.Sanitize(commit.GetMessage()), + Message: sanitize.Content(commit.GetMessage()), } if commit.Author != nil { @@ -1942,13 +1942,16 @@ type MinimalPageInfo struct { // 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"` + Body string `json:"body,omitempty"` + Path string `json:"path"` + Line *int `json:"line,omitempty"` + OriginalLine *int `json:"original_line,omitempty"` + StartLine *int `json:"start_line,omitempty"` + OriginalStartLine *int `json:"original_start_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. @@ -1997,7 +2000,7 @@ func convertToMinimalPullRequestCommits(commits []*github.RepositoryCommit) []Mi } if commit.Commit != nil { - minimalCommit.Message = sanitize.Sanitize(commit.Commit.GetMessage()) + minimalCommit.Message = sanitize.Content(commit.Commit.GetMessage()) minimalCommit.Author = convertToMinimalCommitAuthor(commit.Commit.Author) } @@ -2035,8 +2038,8 @@ func convertToMinimalRelease(release *github.RepositoryRelease) MinimalRelease { m := MinimalRelease{ ID: release.GetID(), TagName: release.GetTagName(), - Name: sanitize.Sanitize(release.GetName()), - Body: sanitize.Sanitize(release.GetBody()), + Name: sanitize.PlainText(release.GetName()), + Body: sanitize.Content(release.GetBody()), HTMLURL: release.GetHTMLURL(), Prerelease: release.GetPrerelease(), Draft: release.GetDraft(), @@ -2092,7 +2095,7 @@ func convertToMinimalWorkflowRun(workflowRun *github.WorkflowRun) MinimalWorkflo if headCommit := workflowRun.GetHeadCommit(); headCommit != nil && headCommit.GetMessage() != "" { minimalRun.HeadCommit = &MinimalWorkflowRunHeadCommit{ - Message: sanitize.Sanitize(headCommit.GetMessage()), + Message: sanitize.Content(headCommit.GetMessage()), } } @@ -2277,7 +2280,7 @@ func convertToMinimalReviewThread(thread reviewThreadNode) MinimalReviewThread { func convertToMinimalReviewComment(c reviewCommentNode) MinimalReviewComment { m := MinimalReviewComment{ - Body: sanitize.Sanitize(string(c.Body)), + Body: sanitize.Content(string(c.Body)), Path: string(c.Path), Author: string(c.Author.Login), HTMLURL: c.URL.String(), @@ -2287,6 +2290,18 @@ func convertToMinimalReviewComment(c reviewCommentNode) MinimalReviewComment { line := int(*c.Line) m.Line = &line } + if c.OriginalLine != nil { + originalLine := int(*c.OriginalLine) + m.OriginalLine = &originalLine + } + if c.StartLine != nil { + startLine := int(*c.StartLine) + m.StartLine = &startLine + } + if c.OriginalStartLine != nil { + originalStartLine := int(*c.OriginalStartLine) + m.OriginalStartLine = &originalStartLine + } if !c.CreatedAt.IsZero() { m.CreatedAt = c.CreatedAt.Format(time.RFC3339) diff --git a/pkg/github/notification_output_fidelity_test.go b/pkg/github/notification_output_fidelity_test.go new file mode 100644 index 0000000000..7fe969e876 --- /dev/null +++ b/pkg/github/notification_output_fidelity_test.go @@ -0,0 +1,77 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNotificationOutputFidelity(t *testing.T) { + for _, subjectType := range []string{"Issue", "PullRequest", "Discussion", "FutureSubject"} { + t.Run(subjectType, func(t *testing.T) { + notification := &github.Notification{ + ID: new("123"), Reason: new("agent_session_finished"), + Subject: &github.NotificationSubject{ + Type: new(subjectType), + URL: new("https://github.example/api/v3/repos/owner/repo/issues/42"), + LatestCommentURL: new("legacy-only-comment-url"), + }, + URL: new("legacy-only-thread-url"), + } + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetNotifications: mockResponse(t, http.StatusOK, []*github.Notification{notification}), + GetReposNotificationsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.Notification{notification}), + GetNotificationsThreadsByThreadID: mockResponse(t, http.StatusOK, notification), + }))} + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + server := mcp.NewServer(&mcp.Implementation{Name: "notification-fidelity", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + for _, tool := range []inventory.ServerTool{ + ListNotifications(translations.NullTranslationHelper), + GetNotificationDetails(translations.NullTranslationHelper), + } { + tool.RegisterFunc(server, deps) + } + client := connectCommentVisibilityClient(t, server, protocol) + for _, call := range []mcp.CallToolParams{ + {Name: "list_notifications", Arguments: map[string]any{}}, + {Name: "list_notifications", Arguments: map[string]any{"owner": "owner", "repo": "repo"}}, + {Name: "get_notification_details", Arguments: map[string]any{"notificationID": "123"}}, + } { + result, err := client.CallTool(context.Background(), &call) + require.NoError(t, err) + require.False(t, result.IsError, "%s", getTextResult(t, result).Text) + if protocol == "2025-11-25" { + var expected any = notification + if call.Name == "list_notifications" { + expected = []*github.Notification{notification} + } + assert.Equal(t, mustMarshalJSON(t, expected), getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + continue + } + require.NotNil(t, result.StructuredContent) + text := mustMarshalJSON(t, result.StructuredContent) + assert.JSONEq(t, text, getTextResult(t, result).Text) + assert.Contains(t, text, `"reason":"agent_session_finished"`) + assert.Contains(t, text, `"type":"`+subjectType+`"`) + assert.Contains(t, text, `"url":"`+notification.Subject.GetURL()+`"`) + assert.NotContains(t, text, "legacy-only") + resolved, err := notificationOutputSchema(call.Name == "list_notifications").Resolve(nil) + require.NoError(t, err) + var output any + require.NoError(t, json.Unmarshal([]byte(text), &output)) + require.NoError(t, resolved.Validate(output)) + } + } + }) + } +} diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index a637951f0a..7558b07edf 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -8,13 +8,13 @@ import ( "net/http" "time" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v89/github" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -27,11 +27,12 @@ const ( // ListNotifications creates a tool to list notifications for the current user. func ListNotifications(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ListNotificationsInput, []*NotificationOutput]( ToolsetMetadataNotifications, mcp.Tool{ - Name: "list_notifications", - Description: t("TOOL_LIST_NOTIFICATIONS_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."), + Name: "list_notifications", + OutputSchema: notificationOutputSchema(true), + Description: t("TOOL_LIST_NOTIFICATIONS_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."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_NOTIFICATIONS_USER_TITLE", "List notifications"), ReadOnlyHint: true, @@ -64,7 +65,11 @@ func ListNotifications(t translations.TranslationHelperFunc) inventory.ServerToo }), }, scopes.RequireAll(scopes.Notifications), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListNotificationsInput) (*mcp.CallToolResult, []*NotificationOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -157,18 +162,24 @@ func ListNotifications(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + output := make([]*NotificationOutput, 0, len(notifications)) + for _, notification := range notifications { + output = append(output, notificationOutput(notification)) + } + return utils.NewToolResultText(string(r)), output, nil }, + normalizeDiscussionNotificationIntegers("page", "perPage"), ) } // DismissNotification creates a tool to mark a notification as read/done. func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[DismissNotificationInput, *NotificationStatusOutput]( ToolsetMetadataNotifications, mcp.Tool{ - Name: "dismiss_notification", - Description: t("TOOL_DISMISS_NOTIFICATION_DESCRIPTION", "Dismiss a notification by marking it as read or done"), + Name: "dismiss_notification", + OutputSchema: discussionNotificationOutputSchema[NotificationStatusOutput](), + Description: t("TOOL_DISMISS_NOTIFICATION_DESCRIPTION", "Dismiss a notification by marking it as read or done"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_DISMISS_NOTIFICATION_USER_TITLE", "Dismiss notification"), ReadOnlyHint: false, @@ -190,7 +201,11 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT }, }, scopes.RequireAll(scopes.Notifications), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input DismissNotificationInput) (*mcp.CallToolResult, *NotificationStatusOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -233,18 +248,21 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, fmt.Sprintf("failed to mark notification as %s", state), resp, body), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Notification marked as %s", state)), nil, nil + message := fmt.Sprintf("Notification marked as %s", state) + inventory.PreserveToolHandlerContent(ctx) + return utils.NewToolResultText(message), &NotificationStatusOutput{Message: message}, nil }, ) } // MarkAllNotificationsRead creates a tool to mark all notifications as read. func MarkAllNotificationsRead(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[MarkAllNotificationsReadInput, *NotificationStatusOutput]( ToolsetMetadataNotifications, mcp.Tool{ - Name: "mark_all_notifications_read", - Description: t("TOOL_MARK_ALL_NOTIFICATIONS_READ_DESCRIPTION", "Mark all notifications as read"), + Name: "mark_all_notifications_read", + OutputSchema: discussionNotificationOutputSchema[NotificationStatusOutput](), + Description: t("TOOL_MARK_ALL_NOTIFICATIONS_READ_DESCRIPTION", "Mark all notifications as read"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_MARK_ALL_NOTIFICATIONS_READ_USER_TITLE", "Mark all notifications as read"), ReadOnlyHint: false, @@ -268,7 +286,11 @@ func MarkAllNotificationsRead(t translations.TranslationHelperFunc) inventory.Se }, }, scopes.RequireAll(scopes.Notifications), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input MarkAllNotificationsReadInput) (*mcp.CallToolResult, *NotificationStatusOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -325,18 +347,20 @@ func MarkAllNotificationsRead(t translations.TranslationHelperFunc) inventory.Se return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to mark all notifications as read", resp, body), nil, nil } - return utils.NewToolResultText("All notifications marked as read"), nil, nil + inventory.PreserveToolHandlerContent(ctx) + return utils.NewToolResultText("All notifications marked as read"), &NotificationStatusOutput{Message: "All notifications marked as read"}, nil }, ) } // GetNotificationDetails creates a tool to get details for a specific notification. func GetNotificationDetails(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetNotificationDetailsInput, *NotificationOutput]( ToolsetMetadataNotifications, mcp.Tool{ - Name: "get_notification_details", - Description: t("TOOL_GET_NOTIFICATION_DETAILS_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."), + Name: "get_notification_details", + OutputSchema: notificationOutputSchema(false), + Description: t("TOOL_GET_NOTIFICATION_DETAILS_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."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_NOTIFICATION_DETAILS_USER_TITLE", "Get notification details"), ReadOnlyHint: true, @@ -353,7 +377,11 @@ func GetNotificationDetails(t translations.TranslationHelperFunc) inventory.Serv }, }, scopes.RequireAll(scopes.Notifications), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input GetNotificationDetailsInput) (*mcp.CallToolResult, *NotificationOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -393,7 +421,7 @@ func GetNotificationDetails(t translations.TranslationHelperFunc) inventory.Serv // delivered to a specific recipient and may reference private // repositories, so confidentiality is private. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelNotificationDetails()) - return result, nil, nil + return result, notificationOutput(thread), nil }, ) } @@ -407,15 +435,16 @@ const ( // ManageNotificationSubscription creates a tool to manage a notification subscription (ignore, watch, delete) func ManageNotificationSubscription(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ManageNotificationSubscriptionInput, *NotificationSubscriptionResult]( ToolsetMetadataNotifications, mcp.Tool{ - Name: "manage_notification_subscription", - Description: t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a notification subscription: ignore, watch, or delete a notification thread subscription."), + Name: "manage_notification_subscription", + OutputSchema: notificationSubscriptionResultSchema(), + Description: t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a notification subscription: ignore, watch, or delete a notification thread subscription."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage notification subscription"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), + DestructiveHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -434,7 +463,11 @@ func ManageNotificationSubscription(t translations.TranslationHelperFunc) invent }, }, scopes.RequireAll(scopes.Notifications), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ManageNotificationSubscriptionInput) (*mcp.CallToolResult, *NotificationSubscriptionResult, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -451,7 +484,7 @@ func ManageNotificationSubscription(t translations.TranslationHelperFunc) invent var ( resp *github.Response - result any + result *github.Subscription apiErr error ) @@ -484,14 +517,15 @@ func ManageNotificationSubscription(t translations.TranslationHelperFunc) invent if action == NotificationActionDelete { // Special case for delete as there is no response body - return utils.NewToolResultText("Notification subscription deleted"), nil, nil + inventory.PreserveToolHandlerContent(ctx) + return utils.NewToolResultText("Notification subscription deleted"), &NotificationSubscriptionResult{Message: "Notification subscription deleted"}, nil } r, err := json.Marshal(result) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), notificationSubscriptionOutput(result), nil }, ) } @@ -504,15 +538,16 @@ const ( // ManageRepositoryNotificationSubscription creates a tool to manage a repository notification subscription (ignore, watch, delete) func ManageRepositoryNotificationSubscription(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ManageRepositoryNotificationSubscriptionInput, *NotificationSubscriptionResult]( ToolsetMetadataNotifications, mcp.Tool{ - Name: "manage_repository_notification_subscription", - Description: t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a repository notification subscription: ignore, watch, or delete repository notifications subscription for the provided repository."), + Name: "manage_repository_notification_subscription", + OutputSchema: notificationSubscriptionResultSchema(), + Description: t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a repository notification subscription: ignore, watch, or delete repository notifications subscription for the provided repository."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage repository notification subscription"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), + DestructiveHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -535,7 +570,11 @@ func ManageRepositoryNotificationSubscription(t translations.TranslationHelperFu }, }, scopes.RequireAll(scopes.Notifications), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ManageRepositoryNotificationSubscriptionInput) (*mcp.CallToolResult, *NotificationSubscriptionResult, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -556,7 +595,7 @@ func ManageRepositoryNotificationSubscription(t translations.TranslationHelperFu var ( resp *github.Response - result any + result *github.Subscription apiErr error ) @@ -592,14 +631,15 @@ func ManageRepositoryNotificationSubscription(t translations.TranslationHelperFu if action == RepositorySubscriptionActionDelete { // Special case for delete as there is no response body - return utils.NewToolResultText("Repository subscription deleted"), nil, nil + inventory.PreserveToolHandlerContent(ctx) + return utils.NewToolResultText("Repository subscription deleted"), &NotificationSubscriptionResult{Message: "Repository subscription deleted"}, nil } r, err := json.Marshal(result) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), notificationSubscriptionOutput(result), nil }, ) } diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go index f2cbaff9f4..0fb37eebc8 100644 --- a/pkg/github/notifications_test.go +++ b/pkg/github/notifications_test.go @@ -6,9 +6,9 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -35,8 +35,8 @@ func Test_ListNotifications(t *testing.T) { // All fields are optional, so Required should be empty assert.Empty(t, schema.Required) mockNotification := &github.Notification{ - ID: github.Ptr("123"), - Reason: github.Ptr("mention"), + ID: new("123"), + Reason: new("mention"), } tests := []struct { @@ -156,8 +156,8 @@ func Test_ManageNotificationSubscription(t *testing.T) { assert.Contains(t, schema.Properties, "action") assert.Equal(t, []string{"notificationID", "action"}, schema.Required) - mockSub := &github.Subscription{Ignored: github.Ptr(true)} - mockSubWatch := &github.Subscription{Ignored: github.Ptr(false), Subscribed: github.Ptr(true)} + mockSub := &github.Subscription{Ignored: new(true)} + mockSubWatch := &github.Subscription{Ignored: new(false), Subscribed: new(true)} tests := []struct { name string @@ -179,7 +179,7 @@ func Test_ManageNotificationSubscription(t *testing.T) { "action": "ignore", }, expectError: false, - expectIgnored: github.Ptr(true), + expectIgnored: new(true), }, { name: "watch subscription", @@ -191,7 +191,7 @@ func Test_ManageNotificationSubscription(t *testing.T) { "action": "watch", }, expectError: false, - expectIgnored: github.Ptr(false), + expectIgnored: new(false), }, { name: "delete subscription", @@ -296,8 +296,8 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { assert.Contains(t, schema.Properties, "action") assert.Equal(t, []string{"owner", "repo", "action"}, schema.Required) - mockSub := &github.Subscription{Ignored: github.Ptr(true)} - mockWatchSub := &github.Subscription{Ignored: github.Ptr(false), Subscribed: github.Ptr(true)} + mockSub := &github.Subscription{Ignored: new(true)} + mockWatchSub := &github.Subscription{Ignored: new(false), Subscribed: new(true)} tests := []struct { name string @@ -321,7 +321,7 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { "action": "ignore", }, expectError: false, - expectIgnored: github.Ptr(true), + expectIgnored: new(true), }, { name: "watch subscription", @@ -334,8 +334,8 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { "action": "watch", }, expectError: false, - expectIgnored: github.Ptr(false), - expectSubscribed: github.Ptr(true), + expectIgnored: new(false), + expectSubscribed: new(true), }, { name: "delete subscription", @@ -679,7 +679,7 @@ func Test_GetNotificationDetails(t *testing.T) { assert.Contains(t, schema.Properties, "notificationID") assert.Equal(t, []string{"notificationID"}, schema.Required) - mockThread := &github.Notification{ID: github.Ptr("123"), Reason: github.Ptr("mention")} + mockThread := &github.Notification{ID: new("123"), Reason: new("mention")} tests := []struct { name string diff --git a/pkg/github/params.go b/pkg/github/params.go index a03a6a1581..416020abb7 100644 --- a/pkg/github/params.go +++ b/pkg/github/params.go @@ -6,7 +6,7 @@ import ( "math" "strconv" - "github.com/google/go-github/v89/github" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" ) @@ -348,14 +348,14 @@ 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), + Minimum: new(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), + Minimum: new(1.0), + Maximum: new(100.0), } return schema @@ -367,14 +367,14 @@ 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), + Minimum: new(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), + Minimum: new(1.0), + Maximum: new(100.0), } schema.Properties["after"] = &jsonschema.Schema{ @@ -390,8 +390,8 @@ 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), + Minimum: new(1.0), + Maximum: new(100.0), } schema.Properties["after"] = &jsonschema.Schema{ diff --git a/pkg/github/params_test.go b/pkg/github/params_test.go index 55a5526f77..3c0d7f43d1 100644 --- a/pkg/github/params_test.go +++ b/pkg/github/params_test.go @@ -5,7 +5,7 @@ import ( "math" "testing" - "github.com/google/go-github/v89/github" + "github.com/google/go-github/v92/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index df6d8ac190..436364c7ba 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -11,15 +11,15 @@ import ( "strings" "time" - ghcontext "github.com/github/github-mcp-server/pkg/context" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/sanitize" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v89/github" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/sanitize" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -266,7 +266,7 @@ func convertToMinimalStatusUpdate(node statusUpdateNode) MinimalProjectStatusUpd return MinimalProjectStatusUpdate{ ID: fmt.Sprintf("%v", node.ID), - Body: sanitize.Sanitize(derefString(node.Body)), + Body: sanitize.Content(derefString(node.Body)), Status: derefString(node.Status), CreatedAt: node.CreatedAt.Time.Format(time.RFC3339), StartDate: derefString(node.StartDate), @@ -284,10 +284,11 @@ func derefString(s *githubv4.String) string { // ProjectsList returns the tool and handler for listing GitHub Projects resources. func ProjectsList(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithSchemaOptions[ProjectsListInput, *ProjectsListOutput]( ToolsetMetadataProjects, mcp.Tool{ - Name: "projects_list", + Name: "projects_list", + OutputSchema: projectsListOutputSchema(), 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, items, views, and status updates for a specific project. @@ -341,7 +342,7 @@ Use this tool to list projects for a user or organization, or list project field Type: "string", }, }, - "per_page": { + "perPage": { Type: "number", Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), }, @@ -358,7 +359,8 @@ Use this tool to list projects for a user or organization, or list project field }, }, scopes.RequireAll(scopes.ReadProject), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + projectsSchemaOptions(), + projectsTypedHandler[ProjectsListInput](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 @@ -442,17 +444,19 @@ Use this tool to list projects for a user or organization, or list project field default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - }, + }, decodeProjectsListOutput), + normalizeProjectsRouting("list"), ) return tool } // ProjectsGet returns the tool and handler for getting GitHub Projects resources. func ProjectsGet(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithSchemaOptions[ProjectsGetInput, *ProjectsGetOutput]( ToolsetMetadataProjects, mcp.Tool{ - Name: "projects_get", + Name: "projects_get", + OutputSchema: projectsGetOutputSchema(), Description: t("TOOL_PROJECTS_GET_DESCRIPTION", `Get details about specific GitHub Projects resources. Use this tool to get details about individual projects, project fields, project items, and project views by their unique IDs. `), @@ -522,7 +526,8 @@ Use this tool to get details about individual projects, project fields, project }, }, scopes.RequireAll(scopes.ReadProject), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + projectsSchemaOptions(), + projectsTypedHandler[ProjectsGetInput](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 @@ -625,8 +630,7 @@ Use this tool to get details about individual projects, project fields, project } resolvedIDs, resolveErr := resolveFieldNamesToIDs(ctx, gqlClient, owner, ownerType, projectNumber, fieldNames, "fields") if resolveErr != nil { - var structured *ghErrors.StructuredResolutionError - if errors.As(resolveErr, &structured) { + if structured, ok := errors.AsType[*ghErrors.StructuredResolutionError](resolveErr); ok { return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil } return utils.NewToolResultError(resolveErr.Error()), nil, nil @@ -644,7 +648,8 @@ Use this tool to get details about individual projects, project fields, project default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - }, + }, decodeProjectsGetOutput), + normalizeProjectsRouting("get"), ) return tool } @@ -693,11 +698,10 @@ func updateProjectItemsItemSchema() *jsonschema.Schema { } func projectUpdatedFieldSchema() *jsonschema.Schema { - value := &jsonschema.Schema{ - Description: "The value to apply. Any JSON value is accepted; use null to clear the field.", - } variant := func(required []string, properties map[string]*jsonschema.Schema) *jsonschema.Schema { - properties["value"] = value + properties["value"] = &jsonschema.Schema{ + Description: "The value to apply. Any JSON value is accepted; use null to clear the field.", + } return &jsonschema.Schema{ Type: "object", AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, @@ -728,15 +732,16 @@ func projectUpdatedFieldSchema() *jsonschema.Schema { // ProjectsWrite returns the tool and handler for modifying GitHub Projects resources. func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithSchemaOptions[ProjectsWriteInput, *ProjectsWriteOutput]( ToolsetMetadataProjects, mcp.Tool{ - Name: "projects_write", - Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Create and manage GitHub Projects: create projects, add/update/delete items, bulk-update many items at once, manage views, create status updates, and add iteration fields."), + Name: "projects_write", + OutputSchema: projectsWriteOutputSchema(), + Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Create and manage GitHub Projects: create projects, add/update/delete items, bulk-update many items at once, manage views, create status updates, and add iteration fields."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Manage GitHub Projects"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), + DestructiveHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -892,7 +897,8 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, scopes.RequireAll(scopes.Project), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + projectsSchemaOptions(), + projectsTypedHandler[ProjectsWriteInput](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 @@ -984,8 +990,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { // Resolve the item by (item_owner, item_repo, issue_number). resolvedItemID, resolveErr := resolveItemIDFromIssueArgs(ctx, gqlClient, owner, ownerType, projectNumber, args) if resolveErr != nil { - var structured *ghErrors.StructuredResolutionError - if errors.As(resolveErr, &structured) { + if structured, ok := errors.AsType[*ghErrors.StructuredResolutionError](resolveErr); ok { return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil } return utils.NewToolResultError(resolveErr.Error()), nil, nil @@ -1039,7 +1044,8 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - }, + }, decodeProjectsWriteOutput), + normalizeProjectsRouting("write"), ) return tool } @@ -1252,8 +1258,7 @@ func listProjectItems(ctx context.Context, client *github.Client, gqlClient *git if len(fieldNames) > 0 { resolvedIDs, resolveErr := resolveFieldNamesToIDs(ctx, gqlClient, owner, ownerType, projectNumber, fieldNames, "fields") if resolveErr != nil { - var structured *ghErrors.StructuredResolutionError - if errors.As(resolveErr, &structured) { + if structured, ok := errors.AsType[*ghErrors.StructuredResolutionError](resolveErr); ok { return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil } return utils.NewToolResultError(resolveErr.Error()), nil, nil @@ -1446,8 +1451,7 @@ func fetchProjectItem(ctx context.Context, client *github.Client, owner, ownerTy func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, itemID int64, fieldValue map[string]any) (*mcp.CallToolResult, any, error) { updatePayload, issueField, err := buildUpdateProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, fieldValue) if err != nil { - var structured *ghErrors.StructuredResolutionError - if errors.As(err, &structured) { + if structured, ok := errors.AsType[*ghErrors.StructuredResolutionError](err); ok { return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil } return utils.NewToolResultError(err.Error()), nil, nil @@ -1469,8 +1473,7 @@ func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *gi issueID, resolveErr := projectItemIssueID(projectItem) if resolveErr != nil { - var structured *ghErrors.StructuredResolutionError - if errors.As(resolveErr, &structured) { + if structured, ok := errors.AsType[*ghErrors.StructuredResolutionError](resolveErr); ok { return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil } return utils.NewToolResultError(resolveErr.Error()), nil, nil @@ -1783,7 +1786,7 @@ func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, a return utils.NewToolResultError(err.Error()), false, nil, nil } - perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) + perPage, err := optionalProjectsPerPage(args) if err != nil { return utils.NewToolResultError(err.Error()), false, nil, nil } @@ -1940,7 +1943,7 @@ func listProjectViews(ctx context.Context, gqlClient *githubv4.Client, args map[ if err != nil { return utils.NewToolResultError(err.Error()), false, nil, nil } - perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) + perPage, err := optionalProjectsPerPage(args) if err != nil { return utils.NewToolResultError(err.Error()), false, nil, nil } @@ -2136,8 +2139,7 @@ func createProjectView(ctx context.Context, gqlClient *githubv4.Client, args map } configuration, err := projectViewVisibleFieldsInput(ctx, gqlClient, args, owner, ownerType, projectNumber) if err != nil { - var structured *ghErrors.StructuredResolutionError - if errors.As(err, &structured) { + if structured, ok := errors.AsType[*ghErrors.StructuredResolutionError](err); ok { return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil } return utils.NewToolResultError(err.Error()), nil, nil @@ -2267,8 +2269,7 @@ func updateProjectView(ctx context.Context, gqlClient *githubv4.Client, args map configuration, err := projectViewVisibleFieldsInput(ctx, gqlClient, args, owner, ownerType, projectNumber) if err != nil { - var structured *ghErrors.StructuredResolutionError - if errors.As(err, &structured) { + if structured, ok := errors.AsType[*ghErrors.StructuredResolutionError](err); ok { return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil } return utils.NewToolResultError(err.Error()), nil, nil @@ -2512,8 +2513,23 @@ func invalidIssueFieldValue(field *ResolvedField, hint string) error { ) } +// optionalProjectsPerPage reads the page size for the projects tools. +// +// The schema advertises perPage, the name every other paginated tool uses. The +// projects tools advertised per_page from September 2025 until this change and +// clients sending it get the size they asked for today, so it is still read when +// perPage is absent. +func optionalProjectsPerPage(args map[string]any) (int, error) { + if _, ok := args["perPage"]; !ok { + if _, legacy := args["per_page"]; legacy { + return OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) + } + } + return OptionalIntParamWithDefault(args, "perPage", MaxProjectsPerPage) +} + func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsPaginationOptions, error) { - perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) + perPage, err := optionalProjectsPerPage(args) if err != nil { return github.ListProjectsPaginationOptions{}, err } diff --git a/pkg/github/projects_batch.go b/pkg/github/projects_batch.go index 28493a4bf2..cf464f2870 100644 --- a/pkg/github/projects_batch.go +++ b/pkg/github/projects_batch.go @@ -9,9 +9,9 @@ import ( "sync" "time" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v89/github" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) @@ -182,8 +182,7 @@ func updateProjectItemsBatch(ctx context.Context, client *github.Client, gqlClie } func batchTopLevelError(err error) *mcp.CallToolResult { - var structured *ghErrors.StructuredResolutionError - if errors.As(err, &structured) { + if structured, ok := errors.AsType[*ghErrors.StructuredResolutionError](err); ok { return ghErrors.NewStructuredResolutionErrorResponse(structured) } return utils.NewToolResultError(err.Error()) @@ -796,8 +795,7 @@ func resolveIssueRefs(ctx context.Context, gqlClient *githubv4.Client, projectID } func batchErrorFromResolution(err error) *batchItemError { - var structured *ghErrors.StructuredResolutionError - if errors.As(err, &structured) { + if structured, ok := errors.AsType[*ghErrors.StructuredResolutionError](err); ok { return &batchItemError{ Code: structured.Kind, Message: fmt.Sprintf("%s: %s", structured.Kind, structured.Name), diff --git a/pkg/github/projects_batch_mutation.go b/pkg/github/projects_batch_mutation.go index 0c478aabf9..13913e53bf 100644 --- a/pkg/github/projects_batch_mutation.go +++ b/pkg/github/projects_batch_mutation.go @@ -105,7 +105,7 @@ func executeAliasedMutation(ctx context.Context, gqlClient *githubv4.Client, kin outcomes := make([]mutationAliasOutcome, len(inputs)) elem := mutationPtr.Elem() for i := range inputs { - result, ok := elem.Field(i).Interface().(projectV2ItemMutationResult) + result, ok := reflect.TypeAssert[projectV2ItemMutationResult](elem.Field(i)) if !ok || result.ProjectV2Item.ID == "" { continue } diff --git a/pkg/github/projects_batch_test.go b/pkg/github/projects_batch_test.go index 985cd5bfc7..32b7522a31 100644 --- a/pkg/github/projects_batch_test.go +++ b/pkg/github/projects_batch_test.go @@ -14,10 +14,10 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/githubv4mock" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -396,10 +396,10 @@ func Test_ProjectsWrite_UpdateProjectItems_NumericItemIDDeduplicatesRESTLookup(t } gqlClient := newTestGQLClient(transport) - var restCalls int32 + var restCalls atomic.Int32 restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetOrgsProjectsV2ItemsByProjectByItemID: func(w http.ResponseWriter, _ *http.Request) { - atomic.AddInt32(&restCalls, 1) + restCalls.Add(1) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"id":1001,"node_id":"PVTI_item1001"}`)) @@ -427,7 +427,7 @@ func Test_ProjectsWrite_UpdateProjectItems_NumericItemIDDeduplicatesRESTLookup(t require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) assert.Equal(t, float64(1), response["succeeded"]) assert.Equal(t, float64(1), response["failed"]) - assert.Equal(t, int32(1), atomic.LoadInt32(&restCalls), "the same numeric item_id must only be resolved once") + assert.Equal(t, int32(1), restCalls.Load(), "the same numeric item_id must only be resolved once") results := response["results"].([]any) assert.Equal(t, "duplicate_target", results[1].(map[string]any)["error"].(map[string]any)["code"]) } @@ -581,10 +581,10 @@ func Test_ProjectsWrite_UpdateProjectItems_DuplicateTargetRejected(t *testing.T) }, } gqlClient := newTestGQLClient(transport) - var restCalls int32 + var restCalls atomic.Int32 restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetOrgsProjectsV2ItemsByProjectByItemID: func(w http.ResponseWriter, _ *http.Request) { - atomic.AddInt32(&restCalls, 1) + restCalls.Add(1) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":1001,"node_id":"PVTI_item1"}`)) }, @@ -616,7 +616,7 @@ func Test_ProjectsWrite_UpdateProjectItems_DuplicateTargetRejected(t *testing.T) second := results[1].(map[string]any) assert.Equal(t, "failed", second["status"]) assert.Equal(t, "duplicate_target", second["error"].(map[string]any)["code"]) - assert.Equal(t, int32(1), atomic.LoadInt32(&restCalls)) + assert.Equal(t, int32(1), restCalls.Load()) assert.Len(t, transport.mutationCalls, 1) } diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go index 33e82d1de5..9716d36a93 100644 --- a/pkg/github/projects_resolver.go +++ b/pkg/github/projects_resolver.go @@ -6,8 +6,8 @@ import ( "strconv" "strings" - ghcontext "github.com/github/github-mcp-server/pkg/context" - ghErrors "github.com/github/github-mcp-server/pkg/errors" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" "github.com/shurcooL/githubv4" ) @@ -606,7 +606,7 @@ func resolveFieldNamesToIDsFromFields(all []ResolvedField, names []string, owner for i, field := range resolved { id, parseErr := parseInt64(field.ID) if parseErr != nil { - return nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass it via '%s' instead", names[i], field.ID, idParameter) + return nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass it via '%s' instead", names[i], field.ID, idParameter) //nolint:gosec // G602: resolveFieldsByName returns exactly one field per name in order, or an error. } out = append(out, id) } diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go index 459c6f1192..769572b411 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -6,10 +6,10 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/githubv4mock" - "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/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/v2/pkg/http/transport" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index e7d67f5264..9cbbdf26bf 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -6,14 +6,14 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/toolsnaps" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/http/headers" - transportpkg "github.com/github/github-mcp-server/pkg/http/transport" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/v2/pkg/http/transport" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + gogithub "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -37,7 +37,7 @@ func Test_ProjectsList(t *testing.T) { assert.Contains(t, inputSchema.Properties, "query") assert.Contains(t, inputSchema.Properties, "fields") assert.Contains(t, inputSchema.Properties["method"].Enum, projectsMethodListProjectViews) - assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner"}) + assert.ElementsMatch(t, []string{"method", "owner"}, inputSchema.Required) } func Test_ProjectsList_ListProjects(t *testing.T) { @@ -395,6 +395,43 @@ func Test_ProjectsList_ListProjectItems(t *testing.T) { }) } +func Test_optionalProjectsPerPage(t *testing.T) { + tests := []struct { + name string + args map[string]any + want int + }{ + { + name: "canonical perPage", + args: map[string]any{"perPage": float64(10)}, + want: 10, + }, + { + name: "per_page still read for clients on the previous name", + args: map[string]any{"per_page": float64(10)}, + want: 10, + }, + { + name: "perPage wins when both are sent", + args: map[string]any{"perPage": float64(10), "per_page": float64(25)}, + want: 10, + }, + { + name: "neither sent falls back to the maximum", + args: map[string]any{}, + want: MaxProjectsPerPage, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := optionalProjectsPerPage(tc.args) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + func Test_detectOwnerType(t *testing.T) { t.Run("uses organization account type", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -601,7 +638,7 @@ func Test_ProjectsGet(t *testing.T) { assert.Contains(t, inputSchema.Properties["method"].Enum, projectsMethodGetProjectView) assert.Contains(t, inputSchema.Properties, "field_id") assert.Contains(t, inputSchema.Properties, "item_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"method"}) + assert.ElementsMatch(t, []string{"method"}, inputSchema.Required) } func Test_ProjectsGet_GetProject(t *testing.T) { @@ -897,7 +934,7 @@ func Test_ProjectsWrite(t *testing.T) { assert.Contains(t, inputSchema.Properties["method"].Enum, projectsMethodCreateProjectView) assert.Contains(t, inputSchema.Properties["method"].Enum, projectsMethodUpdateProjectView) assert.Contains(t, inputSchema.Properties["method"].Enum, projectsMethodDeleteProjectView) - assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner"}) + assert.ElementsMatch(t, []string{"method", "owner"}, inputSchema.Required) // Verify DestructiveHint is set assert.NotNil(t, toolDef.Tool.Annotations) @@ -1451,7 +1488,7 @@ func Test_BuildIssueFieldUpdate(t *testing.T) { func Test_ProjectItemIssueID_RejectsNonIssueItems(t *testing.T) { for _, contentType := range []string{"PullRequest", "DraftIssue"} { t.Run(contentType, func(t *testing.T) { - item := &gogithub.ProjectV2Item{ContentType: gogithub.Ptr(gogithub.ProjectV2ItemContentType(contentType))} + item := &gogithub.ProjectV2Item{ContentType: new(gogithub.ProjectV2ItemContentType(contentType))} _, err := projectItemIssueID(item) var structured *ghErrors.StructuredResolutionError require.ErrorAs(t, err, &structured) diff --git a/pkg/github/projects_types.go b/pkg/github/projects_types.go new file mode 100644 index 0000000000..746406ffca --- /dev/null +++ b/pkg/github/projects_types.go @@ -0,0 +1,617 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "reflect" + + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ProjectParameter retains the original JSON for the legacy method-specific +// validators. In particular, invalid or unused arguments must not fail before +// client acquisition, and numeric strings must retain their original coercion. +type ProjectParameter[T any] struct { + Value T + raw json.RawMessage +} + +func (p *ProjectParameter[T]) UnmarshalJSON(raw []byte) error { + p.raw = append(p.raw[:0], raw...) + // A value of the wrong type is reported by the selected method, not here. + var value T + if err := json.Unmarshal(raw, &value); err == nil { + p.Value = value + } + return nil +} + +func (p ProjectParameter[T]) MarshalJSON() ([]byte, error) { + if p.raw != nil { + return p.raw, nil + } + return json.Marshal(p.Value) +} + +type ProjectsListInput struct { + Method ProjectParameter[string] `json:"method"` + Owner ProjectParameter[string] `json:"owner"` + OwnerType ProjectParameter[string] `json:"owner_type"` + ProjectNumber ProjectParameter[int] `json:"project_number"` + Query ProjectParameter[string] `json:"query"` + Fields ProjectParameter[[]string] `json:"fields"` + FieldNames ProjectParameter[[]string] `json:"field_names"` + PerPage ProjectParameter[int] `json:"perPage"` + LegacyPerPage ProjectParameter[int] `json:"per_page"` + After ProjectParameter[string] `json:"after"` + Before ProjectParameter[string] `json:"before"` +} + +type ProjectsGetInput struct { + Method ProjectParameter[string] `json:"method"` + Owner ProjectParameter[string] `json:"owner"` + OwnerType ProjectParameter[string] `json:"owner_type"` + ProjectNumber ProjectParameter[int] `json:"project_number"` + FieldID ProjectParameter[int64] `json:"field_id"` + ItemID ProjectParameter[int64] `json:"item_id"` + Fields ProjectParameter[[]string] `json:"fields"` + FieldNames ProjectParameter[[]string] `json:"field_names"` + StatusUpdateID ProjectParameter[string] `json:"status_update_id"` + ViewID ProjectParameter[string] `json:"view_id"` +} + +type ProjectsWriteInput struct { + Method ProjectParameter[string] `json:"method"` + Owner ProjectParameter[string] `json:"owner"` + OwnerType ProjectParameter[string] `json:"owner_type"` + ProjectNumber ProjectParameter[int] `json:"project_number"` + Title ProjectParameter[string] `json:"title"` + ViewID ProjectParameter[string] `json:"view_id"` + Name ProjectParameter[string] `json:"name"` + Layout ProjectParameter[string] `json:"layout"` + Filter ProjectParameter[*string] `json:"filter"` + VisibleFields ProjectParameter[[]string] `json:"visible_fields"` + VisibleFieldNames ProjectParameter[[]string] `json:"visible_field_names"` + ItemID ProjectParameter[int64] `json:"item_id"` + ItemType ProjectParameter[string] `json:"item_type"` + ItemOwner ProjectParameter[string] `json:"item_owner"` + ItemRepo ProjectParameter[string] `json:"item_repo"` + IssueNumber ProjectParameter[int] `json:"issue_number"` + PullRequestNumber ProjectParameter[int] `json:"pull_request_number"` + UpdatedField ProjectParameter[ProjectUpdatedFieldInput] `json:"updated_field"` + Items ProjectParameter[[]ProjectItemReference] `json:"items"` + Body ProjectParameter[string] `json:"body"` + Status ProjectParameter[string] `json:"status"` + StartDate ProjectParameter[string] `json:"start_date"` + TargetDate ProjectParameter[string] `json:"target_date"` + FieldName ProjectParameter[string] `json:"field_name"` + IterationDuration ProjectParameter[int] `json:"iteration_duration"` + Iterations ProjectParameter[[]ProjectIterationInput] `json:"iterations"` +} + +func (input ProjectsListInput) MarshalJSON() ([]byte, error) { + return marshalProjectInput(input) +} + +func (input ProjectsGetInput) MarshalJSON() ([]byte, error) { + return marshalProjectInput(input) +} + +func (input ProjectsWriteInput) MarshalJSON() ([]byte, error) { + return marshalProjectInput(input) +} + +func marshalProjectInput(input any) ([]byte, error) { + args, err := projectArguments(input) + if err != nil { + return nil, err + } + return json.Marshal(args) +} + +func (input *ProjectsListInput) UnmarshalJSON(raw []byte) error { + return unmarshalProjectInput(raw, input) +} + +func (input *ProjectsGetInput) UnmarshalJSON(raw []byte) error { + return unmarshalProjectInput(raw, input) +} + +func (input *ProjectsWriteInput) UnmarshalJSON(raw []byte) error { + return unmarshalProjectInput(raw, input) +} + +// Legacy map handlers recognize only exact JSON keys; encoding/json's default +// case-insensitive struct matching must not turn ignored keys into write targets. +func unmarshalProjectInput(raw []byte, input any) error { + var args map[string]json.RawMessage + if err := json.Unmarshal(raw, &args); err != nil { + return err + } + value := reflect.ValueOf(input).Elem() + value.SetZero() + inputType := value.Type() + for i := range value.NumField() { + if argument, ok := args[inputType.Field(i).Tag.Get("json")]; ok { + if err := json.Unmarshal(argument, value.Field(i).Addr().Interface()); err != nil { + return err + } + } + } + return nil +} + +type ProjectUpdatedFieldInput struct { + ID *int64 `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Value json.RawMessage `json:"value"` +} + +type ProjectItemReference struct { + NodeID string `json:"node_id,omitempty"` + ItemID int64 `json:"item_id,omitempty"` + ItemOwner string `json:"item_owner,omitempty"` + ItemRepo string `json:"item_repo,omitempty"` + IssueNumber int `json:"issue_number,omitempty"` +} + +type ProjectIterationInput struct { + Title string `json:"title"` + StartDate string `json:"start_date"` + Duration float64 `json:"duration"` +} + +// The raw map is confined to the compatibility boundary with the existing +// validators and API helpers. Omitted parameters must stay omitted. +func projectArguments(input any) (map[string]any, error) { + value := reflect.ValueOf(input) + inputType := value.Type() + args := make(map[string]any, value.NumField()) + for i := range value.NumField() { + parameter := value.Field(i).Interface().(interface { + argumentJSON() (json.RawMessage, error) + }) + raw, err := parameter.argumentJSON() + if err != nil { + return nil, err + } + if raw == nil { + continue + } + var argument any + if err := json.Unmarshal(raw, &argument); err != nil { + return nil, err + } + args[inputType.Field(i).Tag.Get("json")] = argument + } + return args, nil +} + +func (p ProjectParameter[T]) argumentJSON() (json.RawMessage, error) { + if p.raw != nil { + return p.raw, nil + } + if reflect.ValueOf(&p.Value).Elem().IsZero() { + return nil, nil + } + return json.Marshal(p.Value) +} + +type ProjectsListOutput struct { + Method string `json:"method"` + Projects *ProjectListOutput `json:"projects,omitempty"` + Fields *ProjectFieldListOutput `json:"fields,omitempty"` + Items *ProjectItemListOutput `json:"items,omitempty"` + StatusUpdates *ProjectStatusUpdateListOutput `json:"status_updates,omitempty"` + Views *ProjectViewListOutput `json:"views,omitempty"` +} + +type ProjectListOutput struct { + Projects []MinimalProject `json:"projects"` + PageInfo *pageInfo `json:"pageInfo,omitempty"` + Note string `json:"note,omitempty"` +} + +type ProjectFieldListOutput struct { + Fields []ProjectFieldOutput `json:"fields"` + PageInfo pageInfo `json:"pageInfo"` +} + +// ProjectFieldOutput omits the derivable project API URL from REST fields. +type ProjectFieldOutput struct { + ID *int64 `json:"id,omitempty"` + NodeID *string `json:"node_id,omitempty"` + Name *string `json:"name,omitempty"` + DataType *string `json:"data_type,omitempty"` + Options []ProjectFieldOptionOutput `json:"options,omitempty"` + Configuration *ProjectFieldConfigurationOutput `json:"configuration,omitempty"` + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` +} + +type ProjectFieldOptionOutput struct { + ID *string `json:"id,omitempty"` + Color *string `json:"color,omitempty"` + Description *ProjectFieldTextOutput `json:"description,omitempty"` + Name *ProjectFieldTextOutput `json:"name,omitempty"` +} + +type ProjectFieldTextOutput struct { + HTML *string `json:"html,omitempty"` + Raw *string `json:"raw,omitempty"` +} + +type ProjectFieldConfigurationOutput struct { + Duration *int `json:"duration,omitempty"` + StartDay *int `json:"start_day,omitempty"` + Iterations []ProjectFieldIterationOutput `json:"iterations,omitempty"` +} + +type ProjectFieldIterationOutput struct { + ID *string `json:"id,omitempty"` + Title *ProjectFieldTextOutput `json:"title,omitempty"` + StartDate *string `json:"start_date,omitempty"` + Duration *int `json:"duration,omitempty"` +} + +type ProjectItemListOutput struct { + Items []ProjectItemOutput `json:"items"` + PageInfo pageInfo `json:"pageInfo"` +} + +type ProjectStatusUpdateListOutput struct { + StatusUpdates []MinimalProjectStatusUpdate `json:"statusUpdates"` + PageInfo ProjectGraphQLPageInfo `json:"pageInfo"` +} + +type ProjectViewListOutput struct { + Views []MinimalProjectView `json:"views"` + PageInfo ProjectGraphQLPageInfo `json:"pageInfo"` +} + +type ProjectGraphQLPageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + NextCursor string `json:"nextCursor"` + PrevCursor string `json:"prevCursor"` +} + +type ProjectsGetOutput struct { + Method string `json:"method"` + Project *MinimalProject `json:"project,omitempty"` + Field *ProjectFieldOutput `json:"field,omitempty"` + Item *ProjectItemOutput `json:"item,omitempty"` + StatusUpdate *MinimalProjectStatusUpdate `json:"status_update,omitempty"` + View *MinimalProjectView `json:"view,omitempty"` +} + +type ProjectItemOutput struct { + ID int64 `json:"id"` + NodeID string `json:"node_id,omitempty"` + ContentType string `json:"content_type,omitempty"` + Content *MinimalProjectItemContent `json:"content,omitempty"` + Fields []ProjectFieldValueOutput `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 ProjectFieldValueOutput struct { + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + DataType string `json:"data_type,omitempty"` + // Unknown field types are recursively compacted by the legacy projection, + // including arbitrary object keys. A closed value union would lose data. + Value json.RawMessage `json:"value,omitempty"` +} + +type ProjectsWriteOutput struct { + Method string `json:"method"` + Added *ProjectAddedItemOutput `json:"added,omitempty"` + Item *ProjectItemOutput `json:"item,omitempty"` + IssueFields *ProjectIssueOutput `json:"issue_fields,omitempty"` + Batch *ProjectBatchOutput `json:"batch,omitempty"` + DeletedItem *RepositoryMessageOutput `json:"deleted_item,omitempty"` + StatusUpdate *MinimalProjectStatusUpdate `json:"status_update,omitempty"` + View *MinimalProjectView `json:"view,omitempty"` + DeletedView *ProjectDeletedViewOutput `json:"deleted_view,omitempty"` + Project *ProjectCreatedOutput `json:"project,omitempty"` + IterationField *ProjectIterationFieldOutput `json:"iteration_field,omitempty"` +} + +type ProjectIssueOutput struct { + ID string `json:"id"` + HTMLURL string `json:"html_url"` +} + +type ProjectAddedItemOutput struct { + ID *string `json:"id"` + Message string `json:"message"` + FullDatabaseID string `json:"full_database_id,omitempty"` + ItemID *int64 `json:"item_id,omitempty"` +} + +type ProjectDeletedViewOutput struct { + DeletedViewID string `json:"deleted_view_id"` +} + +type ProjectCreatedOutput struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` +} + +type ProjectIterationFieldOutput struct { + ID string `json:"id"` + Name string `json:"name"` + Configuration struct { + Iterations []ProjectIterationOutput `json:"iterations"` + } `json:"configuration"` +} + +type ProjectIterationOutput struct { + ID string `json:"id"` + Title string `json:"title"` + StartDate string `json:"start_date"` + Duration int `json:"duration"` +} + +type ProjectBatchOutput struct { + Total int `json:"total"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Unknown int `json:"unknown"` + Results []ProjectBatchItemOutput `json:"results"` +} + +type ProjectBatchItemOutput struct { + Index int `json:"index"` + Status batchItemStatus `json:"status"` + Item *batchItemIdentity `json:"item,omitempty"` + Error *ProjectBatchError `json:"error,omitempty"` + // Batch entries echo user-supplied reference objects, including malformed + // references and extra keys, verbatim. + Ref json.RawMessage `json:"ref,omitempty"` +} + +type ProjectBatchError struct { + Code string `json:"code"` + Message string `json:"message"` + Candidates []ProjectResolutionCandidate `json:"candidates,omitempty"` + Hint string `json:"hint,omitempty"` +} + +type ProjectResolutionCandidate struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + DataType string `json:"data_type,omitempty"` +} + +func projectsListOutputSchema() *jsonschema.Schema { + schema := projectOutputSchema[ProjectsListOutput]() + schema.Properties["method"].Enum = []any{projectsMethodListProjects, projectsMethodListProjectFields, projectsMethodListProjectItems, projectsMethodListProjectStatusUpdates, projectsMethodListProjectViews} + return schema +} + +func projectsGetOutputSchema() *jsonschema.Schema { + schema := projectOutputSchema[ProjectsGetOutput]() + schema.Properties["method"].Enum = []any{projectsMethodGetProject, projectsMethodGetProjectField, projectsMethodGetProjectItem, projectsMethodGetProjectStatusUpdate, projectsMethodGetProjectView} + return schema +} + +func projectsWriteOutputSchema() *jsonschema.Schema { + schema := projectOutputSchema[ProjectsWriteOutput]() + schema.Properties["method"].Enum = []any{projectsMethodAddProjectItem, projectsMethodUpdateProjectItem, projectsMethodUpdateProjectItems, projectsMethodDeleteProjectItem, projectsMethodCreateProjectStatusUpdate, projectsMethodCreateProjectView, projectsMethodUpdateProjectView, projectsMethodDeleteProjectView, projectsMethodCreateProject, projectsMethodCreateIterationField} + return schema +} + +func projectOutputSchema[T any]() *jsonschema.Schema { + user := repositoryOutputSchema[MinimalUser]() + delete(user.Properties, "details") + generated, err := inventory.CachedSchemaFor[T](&jsonschema.ForOptions{ + TypeSchemas: map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[github.Timestamp](): {Type: "string", Format: "date-time"}, + reflect.TypeFor[MinimalUser](): user, + }, + }) + if err != nil { + panic(fmt.Sprintf("failed to generate project output schema: %v", err)) + } + schema := forbidOmittedNulls(inventory.CloneSchema(generated)) + var patch func(*jsonschema.Schema) + patch = func(schema *jsonschema.Schema) { + if schema == nil { + return + } + for name, property := range schema.Properties { + switch name { + case "value": + schema.Properties[name] = &jsonschema.Schema{Description: "Recursively compacted JSON field value; unknown field types retain arbitrary object keys."} + case "ref": + schema.Properties[name] = &jsonschema.Schema{Type: "object", Description: "Original batch reference, including malformed or extra fields.", AdditionalProperties: &jsonschema.Schema{}} + case "visible_fields": + property.Type, property.Types = "array", nil + patch(property) + case "created_at", "updated_at", "closed_at", "deleted_at", "archived_at": + property.Description = "RFC3339 timestamp." + case "start_date", "target_date": + property.Description = "Date in YYYY-MM-DD format." + case "duration": + property.Description = "Iteration duration in days." + case "layout": + property.Enum = []any{"board", "table", "roadmap"} + case "status": + if _, batch := schema.Properties["index"]; batch { + property.Enum = []any{"succeeded", "failed", "unknown"} + } else { + property.Enum = []any{"INACTIVE", "ON_TRACK", "AT_RISK", "OFF_TRACK", "COMPLETE"} + } + default: + patch(property) + } + } + patch(schema.Items) + for _, definition := range schema.Defs { + patch(definition) + } + } + patch(schema) + return schema +} + +func decodeProjectsListOutput(method string, raw []byte) (*ProjectsListOutput, error) { + out := &ProjectsListOutput{Method: method} + switch method { + case projectsMethodListProjects: + return out, json.Unmarshal(raw, &out.Projects) + case projectsMethodListProjectFields: + return out, json.Unmarshal(raw, &out.Fields) + case projectsMethodListProjectItems: + return out, json.Unmarshal(raw, &out.Items) + case projectsMethodListProjectStatusUpdates: + return out, json.Unmarshal(raw, &out.StatusUpdates) + case projectsMethodListProjectViews: + return out, json.Unmarshal(raw, &out.Views) + default: + return nil, fmt.Errorf("unexpected Projects list output method %q", method) + } +} + +func decodeProjectsGetOutput(method string, raw []byte) (*ProjectsGetOutput, error) { + out := &ProjectsGetOutput{Method: method} + switch method { + case projectsMethodGetProject: + return out, json.Unmarshal(raw, &out.Project) + case projectsMethodGetProjectField: + return out, json.Unmarshal(raw, &out.Field) + case projectsMethodGetProjectItem: + return out, json.Unmarshal(raw, &out.Item) + case projectsMethodGetProjectStatusUpdate: + return out, json.Unmarshal(raw, &out.StatusUpdate) + case projectsMethodGetProjectView: + return out, json.Unmarshal(raw, &out.View) + default: + return nil, fmt.Errorf("unexpected Projects get output method %q", method) + } +} + +func decodeProjectsWriteOutput(method string, raw []byte) (*ProjectsWriteOutput, error) { + out := &ProjectsWriteOutput{Method: method} + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return out, nil + } + switch method { + case projectsMethodAddProjectItem: + return out, json.Unmarshal(raw, &out.Added) + case projectsMethodUpdateProjectItem: + var discriminator struct { + URL *string `json:"url"` + } + if err := json.Unmarshal(raw, &discriminator); err != nil { + return nil, err + } + if discriminator.URL != nil { + var issue MinimalResponse + if err := json.Unmarshal(raw, &issue); err != nil { + return nil, err + } + out.IssueFields = &ProjectIssueOutput{ID: issue.ID, HTMLURL: issue.URL} + return out, nil + } + return out, json.Unmarshal(raw, &out.Item) + case projectsMethodUpdateProjectItems: + return out, json.Unmarshal(raw, &out.Batch) + case projectsMethodDeleteProjectItem: + out.DeletedItem = &RepositoryMessageOutput{Message: string(raw)} + return out, nil + case projectsMethodCreateProjectStatusUpdate: + return out, json.Unmarshal(raw, &out.StatusUpdate) + case projectsMethodCreateProjectView, projectsMethodUpdateProjectView: + return out, json.Unmarshal(raw, &out.View) + case projectsMethodDeleteProjectView: + return out, json.Unmarshal(raw, &out.DeletedView) + case projectsMethodCreateProject: + var project struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + } + if err := json.Unmarshal(raw, &project); err != nil { + return nil, err + } + out.Project = &ProjectCreatedOutput{ID: project.ID, Number: project.Number, Title: project.Title, HTMLURL: project.URL} + return out, nil + case projectsMethodCreateIterationField: + return out, json.Unmarshal(raw, &out.IterationField) + default: + return nil, fmt.Errorf("unexpected Projects write output method %q", method) + } +} + +func projectsTypedHandler[In, Out any]( + handler func(context.Context, ToolDependencies, *mcp.CallToolRequest, map[string]any) (*mcp.CallToolResult, any, error), + decode func(string, []byte) (Out, error), +) func(context.Context, ToolDependencies, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error) { + return func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, input In) (*mcp.CallToolResult, Out, error) { + var zero Out + args, err := projectArguments(input) + if err != nil { + return nil, zero, fmt.Errorf("decode Projects arguments: %w", err) + } + result, _, err := handler(ctx, deps, req, args) + if err != nil || result == nil || result.IsError { + return result, zero, err + } + if len(result.Content) != 1 { + return nil, zero, fmt.Errorf("expected one Projects text result") + } + text, ok := result.Content[0].(*mcp.TextContent) + if !ok { + return nil, zero, fmt.Errorf("expected Projects text content") + } + method, err := RequiredParam[string](args, "method") + if err != nil { + return nil, zero, err + } + output, err := decode(method, []byte(text.Text)) + if err != nil { + return nil, zero, fmt.Errorf("decode %s output: %w", method, err) + } + return result, output, nil + } +} + +func projectsSchemaOptions() inventory.TypedSchemaOptions { + // Legacy Projects validators inspect only arguments consumed by the selected + // method, after routing and dependency acquisition. Discovery stays strict. + return inventory.TypedSchemaOptions{ + ValidationInputSchema: &jsonschema.Schema{Type: "object"}, + } +} + +func normalizeProjectsRouting(kind string) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, &inventory.ToolInputError{Message: "invalid arguments: " + err.Error()} + } + method, err := RequiredParam[string](args, "method") + if err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + if kind == "get" && (method == projectsMethodGetProjectStatusUpdate || method == projectsMethodGetProjectView) { + delete(args, "owner") + } else if _, err := RequiredParam[string](args, "owner"); err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + return json.Marshal(args) + } +} diff --git a/pkg/github/projects_v2_test.go b/pkg/github/projects_v2_test.go index 2f397e7fce..f5255e99e5 100644 --- a/pkg/github/projects_v2_test.go +++ b/pkg/github/projects_v2_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/prompts.go b/pkg/github/prompts.go index 0c1ac2e9ea..23c4bdfc99 100644 --- a/pkg/github/prompts.go +++ b/pkg/github/prompts.go @@ -1,8 +1,8 @@ package github import ( - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" ) // AllPrompts returns all prompts with their embedded toolset metadata. diff --git a/pkg/github/public_repo_scopes_test.go b/pkg/github/public_repo_scopes_test.go new file mode 100644 index 0000000000..4d959bcb12 --- /dev/null +++ b/pkg/github/public_repo_scopes_test.go @@ -0,0 +1,105 @@ +package github + +import ( + "testing" + + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPublicRepoContributionToolScopeAccess(t *testing.T) { + t.Parallel() + + tools := []struct { + name string + tool inventory.ServerTool + }{ + {name: "fork_repository", tool: ForkRepository(translations.NullTranslationHelper)}, + {name: "create_branch", tool: CreateBranch(translations.NullTranslationHelper)}, + {name: "create_pull_request", tool: CreatePullRequest(translations.NullTranslationHelper)}, + {name: "issue_write", tool: IssueWrite(translations.NullTranslationHelper)}, + {name: "add_issue_comment", tool: AddIssueComment(translations.NullTranslationHelper)}, + {name: "update_issue_comment", tool: UpdateIssueComment(translations.NullTranslationHelper)}, + } + + for _, tt := range tools { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, []string{string(scopes.Repo)}, tt.tool.ScopeAccess.Scopes) + require.NotNil(t, tt.tool.ScopeAccess.Visible) + assert.False(t, tt.tool.ScopeAccess.Visible(nil)) + assert.True(t, tt.tool.ScopeAccess.Visible([]string{string(scopes.PublicRepo)})) + assert.True(t, tt.tool.ScopeAccess.Visible([]string{string(scopes.Repo)})) + + require.NotNil(t, tt.tool.ScopeAccess.Challenge) + assert.Equal(t, []string{string(scopes.Repo)}, tt.tool.ScopeAccess.Challenge(nil, nil)) + assert.Equal(t, []string{string(scopes.Repo)}, tt.tool.ScopeAccess.Challenge(nil, []string{string(scopes.PublicRepo)})) + assert.Empty(t, tt.tool.ScopeAccess.Challenge(nil, []string{string(scopes.Repo)})) + }) + } +} + +func TestPublicRepoContributionToolsVisibleToPATs(t *testing.T) { + t.Parallel() + + tools := []inventory.ServerTool{ + ForkRepository(translations.NullTranslationHelper), + CreateBranch(translations.NullTranslationHelper), + PushFiles(translations.NullTranslationHelper), + CreatePullRequest(translations.NullTranslationHelper), + IssueWrite(translations.NullTranslationHelper), + AddIssueComment(translations.NullTranslationHelper), + UpdateIssueComment(translations.NullTranslationHelper), + } + + tests := []struct { + name string + tokenScopes []string + wantVisible bool + }{ + {name: "no scopes"}, + {name: "public_repo", tokenScopes: []string{string(scopes.PublicRepo)}, wantVisible: true}, + {name: "repo", tokenScopes: []string{string(scopes.Repo)}, wantVisible: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := CreateToolScopeFilter(tt.tokenScopes) + for i := range tools { + included, err := filter(t.Context(), &tools[i]) + require.NoError(t, err) + assert.Equal(t, tt.wantVisible, included, tools[i].Tool.Name) + } + }) + } +} + +func TestPushFilesOAuthScopeChallenges(t *testing.T) { + t.Parallel() + + tool := PushFiles(translations.NullTranslationHelper) + regularFiles := map[string]any{ + "files": []any{map[string]any{"path": "README.md"}}, + } + workflowFiles := map[string]any{ + "files": []any{map[string]any{"path": ".github/workflows/ci.yml"}}, + } + + assert.Equal(t, []string{string(scopes.Repo), string(scopes.Workflow)}, tool.ScopeAccess.Scopes) + require.NotNil(t, tool.ScopeAccess.Visible) + assert.True(t, tool.ScopeAccess.Visible([]string{string(scopes.PublicRepo)})) + assert.True(t, tool.ScopeAccess.Visible([]string{string(scopes.Repo)})) + + assert.Equal(t, []string{string(scopes.Repo)}, tool.ScopeAccess.Challenge(regularFiles, nil)) + assert.Equal(t, []string{string(scopes.Repo)}, tool.ScopeAccess.Challenge(regularFiles, []string{string(scopes.PublicRepo)})) + assert.Empty(t, tool.ScopeAccess.Challenge(regularFiles, []string{string(scopes.Repo)})) + + assert.Equal(t, []string{string(scopes.Repo), string(scopes.Workflow)}, tool.ScopeAccess.Challenge(workflowFiles, nil)) + assert.Equal(t, []string{string(scopes.Repo), string(scopes.Workflow)}, tool.ScopeAccess.Challenge(workflowFiles, []string{string(scopes.PublicRepo)})) + assert.Equal(t, []string{string(scopes.Workflow)}, tool.ScopeAccess.Challenge(workflowFiles, []string{string(scopes.Repo)})) + assert.Equal(t, []string{string(scopes.Repo)}, tool.ScopeAccess.Challenge(workflowFiles, []string{string(scopes.Workflow)})) + assert.Equal(t, []string{string(scopes.Repo)}, tool.ScopeAccess.Challenge(workflowFiles, []string{string(scopes.PublicRepo), string(scopes.Workflow)})) + assert.Empty(t, tool.ScopeAccess.Challenge(workflowFiles, []string{string(scopes.Repo), string(scopes.Workflow)})) +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 38e6e3d820..5545d43964 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -6,20 +6,21 @@ import ( "fmt" "io" "net/http" + "slices" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v89/github" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/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" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/octicons" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" ) // PullRequestRead creates a tool to get details of a specific pull request. @@ -36,7 +37,7 @@ Possible options: 3. get_status - Get combined commit status of a head commit in a pull request. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned. - 6. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. + 6. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns thread metadata and comments with nullable current and original line-range coordinates (line, start_line, original_line, original_start_line). Current coordinates are omitted when unavailable, such as for outdated comments. Use cursor-based pagination (perPage, after) to control results. 7. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned. 8. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. 9. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. @@ -67,11 +68,12 @@ Possible options: 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( + return NewTool[PullRequestReadInput, *PullRequestReadOutput]( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "pull_request_read", - Description: t("TOOL_PULL_REQUEST_READ_DESCRIPTION", "Get information on a specific pull request in GitHub repository."), + Name: "pull_request_read", + OutputSchema: pullRequestReadOutputSchema(), + Description: t("TOOL_PULL_REQUEST_READ_DESCRIPTION", "Get information on a specific pull request in GitHub repository."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_PULL_REQUEST_USER_TITLE", "Get details for a single pull request"), ReadOnlyHint: true, @@ -79,7 +81,11 @@ Possible options: InputSchema: schema, }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input PullRequestReadInput) (*mcp.CallToolResult, *PullRequestReadOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -119,20 +125,20 @@ Possible options: switch method { case "get": - result, err := GetPullRequest(ctx, client, deps, owner, repo, pullNumber) - return attachIFC(result), nil, err + result, output, err := getPullRequest(ctx, client, deps, owner, repo, pullNumber) + return attachIFC(result), output, err case "get_diff": - result, err := GetPullRequestDiff(ctx, client, deps, owner, repo, pullNumber) - return attachIFC(result), nil, err + result, output, err := getPullRequestDiff(ctx, client, deps, owner, repo, pullNumber) + return attachIFC(result), output, err case "get_status": - result, err := GetPullRequestStatus(ctx, client, owner, repo, pullNumber) - return attachIFC(result), nil, err + result, output, err := getPullRequestStatus(ctx, client, owner, repo, pullNumber) + return attachIFC(result), output, err case "get_files": - result, err := GetPullRequestFiles(ctx, client, deps, owner, repo, pullNumber, pagination) - return attachIFC(result), nil, err + result, output, err := getPullRequestFiles(ctx, client, deps, owner, repo, pullNumber, pagination) + return attachIFC(result), output, err case "get_commits": - result, err := GetPullRequestCommits(ctx, client, deps, owner, repo, pullNumber, pagination) - return attachIFC(result), nil, err + result, output, err := getPullRequestCommits(ctx, client, deps, owner, repo, pullNumber, pagination) + return attachIFC(result), output, err case "get_review_comments": gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -142,27 +148,32 @@ Possible options: if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - result, err := GetPullRequestReviewComments(ctx, gqlClient, deps, owner, repo, pullNumber, cursorPagination) - return attachIFC(result), nil, err + result, output, err := getPullRequestReviewComments(ctx, gqlClient, deps, owner, repo, pullNumber, cursorPagination) + return attachIFC(result), output, err case "get_reviews": - result, err := GetPullRequestReviews(ctx, client, deps, owner, repo, pullNumber, pagination) - return attachIFC(result), nil, err + result, output, err := getPullRequestReviews(ctx, client, deps, owner, repo, pullNumber, pagination) + return attachIFC(result), output, err case "get_comments": - result, err := GetIssueComments(ctx, client, deps, owner, repo, pullNumber, pagination) - return attachIFC(result), nil, err + result, output, err := getIssueComments(ctx, client, deps, owner, repo, pullNumber, pagination) + return attachIFC(result), pullRequestCommentsOutput(output), err case "get_check_runs": - result, err := GetPullRequestCheckRuns(ctx, client, owner, repo, pullNumber, pagination) - return attachIFC(result), nil, err + result, output, err := getPullRequestCheckRuns(ctx, client, owner, repo, pullNumber, pagination) + return attachIFC(result), output, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - }) + }, normalizePullRequestArguments("read")) } func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { + result, _, err := getPullRequest(ctx, client, deps, owner, repo, pullNumber) + return result, err +} + +func getPullRequest(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, *PullRequestReadOutput, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { - return nil, fmt.Errorf("failed to get repo access cache: %w", err) + return nil, nil, fmt.Errorf("failed to get repo access cache: %w", err) } ff := deps.GetFlags(ctx) @@ -172,27 +183,31 @@ func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDepende "failed to get pull request", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil, nil + } + + if pr == nil { + return utils.NewToolResultText("null"), &PullRequestReadOutput{}, nil } if ff.LockdownMode { if restricted, err := authorLockdownResult(ctx, cache, owner, repo, pr.GetUser().GetLogin(), lockdownPullRequestRestrictedMessage); restricted != nil || err != nil { - return restricted, err + return restricted, nil, err } } minimalPR := convertToMinimalPullRequest(pr) - return MarshalledTextResult(minimalPR), nil + return MarshalledTextResult(minimalPR), &PullRequestReadOutput{PullRequest: &minimalPR}, nil } // enforcePullRequestLockdown returns a restricted tool result when lockdown mode is @@ -225,8 +240,13 @@ func enforcePullRequestLockdown(ctx context.Context, client *github.Client, deps } func GetPullRequestDiff(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { + result, _, err := getPullRequestDiff(ctx, client, deps, owner, repo, pullNumber) + return result, err +} + +func getPullRequestDiff(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, *PullRequestReadOutput, error) { if restricted, err := enforcePullRequestLockdown(ctx, client, deps, owner, repo, pullNumber); restricted != nil || err != nil { - return restricted, err + return restricted, nil, err } raw, resp, err := client.PullRequests.GetRaw( @@ -241,40 +261,45 @@ func GetPullRequestDiff(ctx context.Context, client *github.Client, deps ToolDep "failed to get pull request diff", resp, err, - ), nil + ), nil, nil } 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 nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request diff", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request diff", resp, body), nil, nil } defer func() { _ = resp.Body.Close() }() // Return the raw response - return utils.NewToolResultText(string(raw)), nil + return utils.NewToolResultText(string(raw)), &PullRequestReadOutput{Diff: &PullRequestDiffOutput{Diff: string(raw)}}, nil } func GetPullRequestStatus(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { + result, _, err := getPullRequestStatus(ctx, client, owner, repo, pullNumber) + return result, err +} + +func getPullRequestStatus(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, *PullRequestReadOutput, error) { 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 + ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil, nil } // Get combined status for the head SHA @@ -284,27 +309,33 @@ func GetPullRequestStatus(ctx context.Context, client *github.Client, owner, rep "failed to get combined status", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get combined status", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get combined status", resp, body), nil, nil } - r, err := json.Marshal(convertToMinimalCombinedStatus(status)) + minimalStatus := convertToMinimalCombinedStatus(status) + r, err := json.Marshal(minimalStatus) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), &PullRequestReadOutput{Status: &minimalStatus}, nil } func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + result, _, err := getPullRequestCheckRuns(ctx, client, owner, repo, pullNumber, pagination) + return result, err +} + +func getPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, *PullRequestReadOutput, error) { // First get the PR to get the head SHA pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) if err != nil { @@ -312,16 +343,16 @@ func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, "failed to get pull request", resp, err, - ), nil + ), nil, 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 nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil, nil } // Get check runs for the head SHA @@ -338,16 +369,16 @@ func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, "failed to get check runs", resp, err, - ), nil + ), nil, 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 nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get check runs", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get check runs", resp, body), nil, nil } // Convert to minimal check runs to reduce context usage @@ -363,15 +394,20 @@ func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, r, err := json.Marshal(minimalResult) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), &PullRequestReadOutput{CheckRuns: &minimalResult}, nil } func GetPullRequestFiles(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + result, _, err := getPullRequestFiles(ctx, client, deps, owner, repo, pullNumber, pagination) + return result, err +} + +func getPullRequestFiles(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, *PullRequestReadOutput, error) { if restricted, err := enforcePullRequestLockdown(ctx, client, deps, owner, repo, pullNumber); restricted != nil || err != nil { - return restricted, err + return restricted, nil, err } opts := &github.ListOptions{ @@ -384,29 +420,34 @@ func GetPullRequestFiles(ctx context.Context, client *github.Client, deps ToolDe "failed to get pull request files", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request files", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request files", resp, body), nil, nil } minimalFiles := convertToMinimalPRFiles(files) - return MarshalledTextResult(minimalFiles), nil + return MarshalledTextResult(minimalFiles), &PullRequestReadOutput{Files: &minimalFiles}, nil } // GetPullRequestCommits returns the commits on a pull request. Under lockdown // mode it checks the PR author once rather than per commit, since every // commit on the PR belongs to the same untrusted head branch. func GetPullRequestCommits(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + result, _, err := getPullRequestCommits(ctx, client, deps, owner, repo, pullNumber, pagination) + return result, err +} + +func getPullRequestCommits(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, *PullRequestReadOutput, error) { if restricted, err := enforcePullRequestLockdown(ctx, client, deps, owner, repo, pullNumber); restricted != nil || err != nil { - return restricted, err + return restricted, nil, err } opts := &github.ListOptions{ @@ -419,21 +460,21 @@ func GetPullRequestCommits(ctx context.Context, client *github.Client, deps Tool "failed to get pull request commits", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request commits", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request commits", resp, body), nil, nil } minimalCommits := convertToMinimalPullRequestCommits(commits) - return MarshalledTextResult(minimalCommits), nil + return MarshalledTextResult(minimalCommits), &PullRequestReadOutput{Commits: &minimalCommits}, nil } // GraphQL types for review threads query @@ -461,11 +502,14 @@ type reviewThreadNode struct { } type reviewCommentNode struct { - ID githubv4.ID - Body githubv4.String - Path githubv4.String - Line *githubv4.Int - Author struct { + ID githubv4.ID + Body githubv4.String + Path githubv4.String + Line *githubv4.Int + OriginalLine *githubv4.Int + StartLine *githubv4.Int + OriginalStartLine *githubv4.Int + Author struct { Login githubv4.String } CreatedAt githubv4.DateTime @@ -481,16 +525,21 @@ type pageInfoFragment struct { } func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination CursorPaginationParams) (*mcp.CallToolResult, error) { + result, _, err := getPullRequestReviewComments(ctx, gqlClient, deps, owner, repo, pullNumber, pagination) + return result, err +} + +func getPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination CursorPaginationParams) (*mcp.CallToolResult, *PullRequestReadOutput, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { - return nil, fmt.Errorf("failed to get repo access cache: %w", err) + return nil, 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 { - return utils.NewToolResultError(fmt.Sprintf("invalid pagination parameters: %v", err)), nil + return utils.NewToolResultError(fmt.Sprintf("invalid pagination parameters: %v", err)), nil, nil } // Build variables for GraphQL query @@ -515,13 +564,13 @@ func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Clien return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get pull request review threads", err, - ), nil + ), nil, nil } // Lockdown mode filtering if ff.LockdownMode { if cache == nil { - return nil, fmt.Errorf("lockdown cache is not configured") + return nil, nil, fmt.Errorf("lockdown cache is not configured") } // Iterate through threads and filter comments @@ -534,7 +583,7 @@ func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Clien if login != "" { isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) if err != nil { - return nil, fmt.Errorf("failed to check lockdown mode: %w", err) + return nil, nil, fmt.Errorf("failed to check lockdown mode: %w", err) } if isSafeContent { filteredComments = append(filteredComments, comment) @@ -547,13 +596,19 @@ func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Clien } } - return MarshalledTextResult(convertToMinimalReviewThreadsResponse(query)), nil + threads := convertToMinimalReviewThreadsResponse(query) + return MarshalledTextResult(threads), &PullRequestReadOutput{ReviewThreads: &threads}, nil } func GetPullRequestReviews(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + result, _, err := getPullRequestReviews(ctx, client, deps, owner, repo, pullNumber, pagination) + return result, err +} + +func getPullRequestReviews(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, *PullRequestReadOutput, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { - return nil, fmt.Errorf("failed to get repo access cache: %w", err) + return nil, nil, fmt.Errorf("failed to get repo access cache: %w", err) } ff := deps.GetFlags(ctx) @@ -566,21 +621,21 @@ func GetPullRequestReviews(ctx context.Context, client *github.Client, deps Tool "failed to get pull request reviews", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request reviews", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request reviews", resp, body), nil, nil } if ff.LockdownMode { if cache == nil { - return nil, fmt.Errorf("lockdown cache is not configured") + return nil, nil, fmt.Errorf("lockdown cache is not configured") } filteredReviews := make([]*github.PullRequestReview, 0, len(reviews)) for _, review := range reviews { @@ -590,7 +645,7 @@ func GetPullRequestReviews(ctx context.Context, client *github.Client, deps Tool } isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) if err != nil { - return nil, fmt.Errorf("failed to check lockdown mode: %w", err) + return nil, nil, fmt.Errorf("failed to check lockdown mode: %w", err) } if isSafeContent { filteredReviews = append(filteredReviews, review) @@ -604,7 +659,7 @@ func GetPullRequestReviews(ctx context.Context, client *github.Client, deps Tool minimalReviews = append(minimalReviews, convertToMinimalPullRequestReview(review)) } - return MarshalledTextResult(minimalReviews), nil + return MarshalledTextResult(minimalReviews), &PullRequestReadOutput{Reviews: &minimalReviews}, nil } // PullRequestWriteUIResourceURI is the URI for the create_pull_request tool's MCP App UI resource. @@ -645,11 +700,12 @@ var pullRequestUpdateFormParams = map[string]struct{}{ // CreatePullRequest creates a tool to create a new pull request. func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[CreatePullRequestInput, *PullRequestWriteOutput]( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "create_pull_request", - Description: t("TOOL_CREATE_PULL_REQUEST_DESCRIPTION", "Create a new pull request in a GitHub repository."), + Name: "create_pull_request", + OutputSchema: pullRequestWriteOutputSchema(), + Description: t("TOOL_CREATE_PULL_REQUEST_DESCRIPTION", "Create a new pull request in a GitHub repository."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_CREATE_PULL_REQUEST_USER_TITLE", "Open new pull request"), ReadOnlyHint: false, @@ -706,8 +762,12 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo", "title", "head", "base"}, }, }, - scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + publicRepositoryWriteScopeAccess(), + func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, input CreatePullRequestInput) (*mcp.CallToolResult, *PullRequestWriteOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -719,15 +779,19 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo // Hand off to the interactive MCP App form unless this call must // execute now (see shouldDeferToForm). - if shouldDeferToForm(ctx, deps, req, args, pullRequestWriteFormParams) { - return utils.NewToolResultAwaitingFormSubmission(fmt.Sprintf( + formArgs, err := pullRequestFormArguments(req, args) + if err != nil { + return nil, nil, err + } + if shouldDeferToForm(ctx, deps, req, formArgs, pullRequestWriteFormParams) { + return pullRequestAwaitingFormResult(fmt.Sprintf( "An interactive form has been shown to the user for creating a new pull request in %s/%s. "+ "STOP — do not call any other tools, do not respond as if the pull request was created, "+ "and do not claim the operation succeeded. The pull request has NOT been created yet; "+ "only the form was rendered. Wait silently for the user to review and click Submit. "+ "When they do, the real result will be delivered to your context automatically.", owner, repo, - )), nil, nil + )) } // When creating PR, title/head/base are required @@ -774,17 +838,17 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo } newPR := &github.CreatePullRequest{ - Title: github.Ptr(title), + Title: new(title), Head: head, Base: base, } if body != "" { - newPR.Body = github.Ptr(body) + newPR.Body = new(body) } - newPR.Draft = github.Ptr(draft) - newPR.MaintainerCanModify = github.Ptr(maintainerCanModify) + newPR.Draft = new(draft) + newPR.MaintainerCanModify = new(maintainerCanModify) client, err := deps.GetClient(ctx) if err != nil { @@ -849,8 +913,8 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil - }) + return utils.NewToolResultText(string(r)), &PullRequestWriteOutput{PullRequest: pullRequestMutationReference(minimalResponse)}, nil + }, normalizePullRequestArguments("create")) } // UpdatePullRequest creates a tool to update an existing pull request. @@ -906,11 +970,12 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo", "pullNumber"}, } - st := NewTool( + st := NewTool[UpdatePullRequestInput, *PullRequestWriteOutput]( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "update_pull_request", - Description: t("TOOL_UPDATE_PULL_REQUEST_DESCRIPTION", "Update an existing pull request in a GitHub repository."), + Name: "update_pull_request", + OutputSchema: pullRequestWriteOutputSchema(), + Description: t("TOOL_UPDATE_PULL_REQUEST_DESCRIPTION", "Update an existing pull request in a GitHub repository."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UPDATE_PULL_REQUEST_USER_TITLE", "Edit pull request"), ReadOnlyHint: false, @@ -924,7 +989,11 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo InputSchema: schema, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, input UpdatePullRequestInput) (*mcp.CallToolResult, *PullRequestWriteOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -940,15 +1009,19 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo // Hand off to the interactive MCP App form unless this call must // execute now (see shouldDeferToForm). - if shouldDeferToForm(ctx, deps, req, args, pullRequestUpdateFormParams) { - return utils.NewToolResultAwaitingFormSubmission(fmt.Sprintf( + formArgs, err := pullRequestFormArguments(req, args) + if err != nil { + return nil, nil, err + } + if shouldDeferToForm(ctx, deps, req, formArgs, pullRequestUpdateFormParams) { + return pullRequestAwaitingFormResult(fmt.Sprintf( "An interactive form has been shown to the user for editing pull request #%d in %s/%s. "+ "STOP — do not call any other tools, do not respond as if the pull request was updated, "+ "and do not claim the operation succeeded. The pull request has NOT been updated yet; "+ "only the form was rendered. Wait silently for the user to review and click Submit. "+ "When they do, the real result will be delivered to your context automatically.", pullNumber, owner, repo, - )), nil, nil + )) } _, draftProvided := args["draft"] @@ -966,35 +1039,35 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo if title, ok, err := OptionalParamOK[string](args, "title"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } else if ok { - update.Title = github.Ptr(title) + update.Title = new(title) restUpdateNeeded = true } if body, ok, err := OptionalParamOK[string](args, "body"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } else if ok { - update.Body = github.Ptr(body) + update.Body = new(body) restUpdateNeeded = true } if state, ok, err := OptionalParamOK[string](args, "state"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } else if ok { - update.State = github.Ptr(state) + update.State = new(state) restUpdateNeeded = true } if base, ok, err := OptionalParamOK[string](args, "base"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } else if ok { - update.Base = &github.PullRequestBranch{Ref: github.Ptr(base)} + update.Base = &github.PullRequestBranch{Ref: new(base)} restUpdateNeeded = true } if maintainerCanModify, ok, err := OptionalParamOK[bool](args, "maintainer_can_modify"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } else if ok { - update.MaintainerCanModify = github.Ptr(maintainerCanModify) + update.MaintainerCanModify = new(maintainerCanModify) restUpdateNeeded = true } @@ -1164,9 +1237,9 @@ 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 utils.NewToolResultText(string(r)), &PullRequestWriteOutput{PullRequest: pullRequestMutationReference(minimalResponse)}, nil + }, normalizePullRequestArguments("update")) + st.FeatureRule = pullRequestsConsolidatedRule return st } @@ -1190,7 +1263,7 @@ func AddReplyToPullRequestComment(t translations.TranslationHelperFunc) inventor "commentId": { Type: "number", Description: "The numeric ID of the pull request review comment to reply or react to. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "body": { Type: "string", @@ -1205,11 +1278,12 @@ func AddReplyToPullRequestComment(t translations.TranslationHelperFunc) inventor Required: []string{"owner", "repo", "commentId"}, } - return NewTool( + return NewTool[AddReplyToPullRequestCommentInput, *PullRequestCommentReplyOutput]( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "add_reply_to_pull_request_comment", - Description: t("TOOL_ADD_REPLY_TO_PULL_REQUEST_COMMENT_DESCRIPTION", "Add a reply and/or reaction to an existing pull request comment. This can create a new comment linked as a reply to the specified comment, add an emoji reaction to the specified comment, or do both. At least one of body or reaction is required."), + Name: "add_reply_to_pull_request_comment", + OutputSchema: pullRequestCommentReplyOutputSchema(), + Description: t("TOOL_ADD_REPLY_TO_PULL_REQUEST_COMMENT_DESCRIPTION", "Add a reply and/or reaction to an existing pull request comment. This can create a new comment linked as a reply to the specified comment, add an emoji reaction to the specified comment, or do both. At least one of body or reaction is required."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_REPLY_TO_PULL_REQUEST_COMMENT_USER_TITLE", "Add reply to pull request comment"), ReadOnlyHint: false, @@ -1217,7 +1291,11 @@ func AddReplyToPullRequestComment(t translations.TranslationHelperFunc) inventor InputSchema: schema, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input AddReplyToPullRequestCommentInput) (*mcp.CallToolResult, *PullRequestCommentReplyOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1299,29 +1377,160 @@ func AddReplyToPullRequestComment(t translations.TranslationHelperFunc) inventor } } - var result any + var output PullRequestCommentReplyOutput + var r []byte switch { case hasBody && hasReaction: - result = map[string]MinimalResponse{ - "comment": *commentResponse, - "reaction": *reactionResponse, + output.ReplyAndReaction = &PullRequestReplyAndReactionOutput{ + Comment: *pullRequestMutationReference(*commentResponse), + Reaction: PullRequestMutationReference{ID: reactionResponse.ID}, } + r, err = json.Marshal(map[string]*MinimalResponse{"comment": commentResponse, "reaction": reactionResponse}) case hasReaction: - result = reactionResponse + output.Response = &PullRequestMutationReference{ID: reactionResponse.ID} + r, err = json.Marshal(reactionResponse) default: - result = commentResponse + output.Response = pullRequestMutationReference(*commentResponse) + r, err = json.Marshal(commentResponse) } - r, err := json.Marshal(result) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil - }) + return utils.NewToolResultText(string(r)), &output, nil + }, normalizePullRequestArguments("reply")) } // ListPullRequests creates a tool to list pull requests in a GitHub repository. +type ListPullRequestsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + State string `json:"state,omitempty"` + Head string `json:"head,omitempty"` + Base string `json:"base,omitempty"` + Sort string `json:"sort,omitempty"` + Direction string `json:"direction,omitempty"` + Fields []string `json:"fields,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type ListPullRequestOutput struct { + Number *int `json:"number,omitempty"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + State *string `json:"state,omitempty" jsonschema:"Pull request state: open or closed."` + Draft *bool `json:"draft,omitempty"` + Merged *bool `json:"merged,omitempty"` + MergeableState *string `json:"mergeable_state,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + 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" jsonschema:"Number of lines added."` + Deletions *int `json:"deletions,omitempty" jsonschema:"Number of lines removed."` + ChangedFiles *int `json:"changed_files,omitempty" jsonschema:"Number of files changed."` + Commits *int `json:"commits,omitempty" jsonschema:"Number of commits in the pull request."` + Comments *int `json:"comments,omitempty" jsonschema:"Number of comments on the pull request."` + CreatedAt *string `json:"created_at,omitempty" jsonschema:"Creation time in RFC 3339 format."` + UpdatedAt *string `json:"updated_at,omitempty" jsonschema:"Last update time in RFC 3339 format."` + ClosedAt *string `json:"closed_at,omitempty" jsonschema:"Closing time in RFC 3339 format."` + MergedAt *string `json:"merged_at,omitempty" jsonschema:"Merge time in RFC 3339 format."` + Milestone *string `json:"milestone,omitempty"` +} + +func structuredListPullRequestsOutput(pullRequests []MinimalPullRequest, fields []string) []ListPullRequestOutput { + output := make([]ListPullRequestOutput, 0, len(pullRequests)) + selected := func(field string) bool { + return len(fields) == 0 || slices.Contains(fields, field) + } + for _, pr := range pullRequests { + item := ListPullRequestOutput{} + if selected("number") { + item.Number = new(pr.Number) + } + if selected("title") { + item.Title = new(pr.Title) + } + if selected("body") && pr.Body != "" { + item.Body = new(pr.Body) + } + if selected("state") { + item.State = new(pr.State) + } + if selected("draft") { + item.Draft = new(pr.Draft) + } + if selected("merged") { + item.Merged = new(pr.Merged) + } + if selected("mergeable_state") && pr.MergeableState != "" { + item.MergeableState = new(pr.MergeableState) + } + if selected("html_url") { + item.HTMLURL = new(pr.HTMLURL) + } + if selected("user") && pr.User != nil { + item.User = pr.User + } + if selected("labels") && len(pr.Labels) > 0 { + item.Labels = &pr.Labels + } + if selected("assignees") && len(pr.Assignees) > 0 { + item.Assignees = &pr.Assignees + } + if selected("requested_reviewers") && len(pr.RequestedReviewers) > 0 { + item.RequestedReviewers = &pr.RequestedReviewers + } + if selected("merged_by") && pr.MergedBy != "" { + item.MergedBy = new(pr.MergedBy) + } + if selected("head") && pr.Head != nil { + item.Head = pr.Head + } + if selected("base") && pr.Base != nil { + item.Base = pr.Base + } + if selected("additions") && pr.Additions != 0 { + item.Additions = new(pr.Additions) + } + if selected("deletions") && pr.Deletions != 0 { + item.Deletions = new(pr.Deletions) + } + if selected("changed_files") && pr.ChangedFiles != 0 { + item.ChangedFiles = new(pr.ChangedFiles) + } + if selected("commits") && pr.Commits != 0 { + item.Commits = new(pr.Commits) + } + if selected("comments") && pr.Comments != 0 { + item.Comments = new(pr.Comments) + } + if selected("created_at") && pr.CreatedAt != "" { + item.CreatedAt = new(pr.CreatedAt) + } + if selected("updated_at") && pr.UpdatedAt != "" { + item.UpdatedAt = new(pr.UpdatedAt) + } + if selected("closed_at") && pr.ClosedAt != "" { + item.ClosedAt = new(pr.ClosedAt) + } + if selected("merged_at") && pr.MergedAt != "" { + item.MergedAt = new(pr.MergedAt) + } + if selected("milestone") && pr.Milestone != "" { + item.Milestone = new(pr.Milestone) + } + output = append(output, item) + } + return output +} + func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", @@ -1366,7 +1575,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool ) WithPagination(schema) - return NewTool( + return NewTool[ListPullRequestsInput, []ListPullRequestOutput]( ToolsetMetadataPullRequests, mcp.Tool{ Name: "list_pull_requests", @@ -1378,50 +1587,27 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool InputSchema: schema, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListPullRequestsInput) (*mcp.CallToolResult, []ListPullRequestOutput, error) { + if input.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - state, err := OptionalParam[string](args, "state") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - head, err := OptionalParam[string](args, "head") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + pagination := PaginationParams{Page: 1, PerPage: 30} + if input.Page != nil { + pagination.Page = *input.Page } - base, err := OptionalParam[string](args, "base") - 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 - } - direction, err := OptionalParam[string](args, "direction") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - fields, err := OptionalStringArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.PerPage != nil { + pagination.PerPage = *input.PerPage } opts := &github.PullRequestListOptions{ - State: state, - Head: head, - Base: base, - Sort: sort, - Direction: direction, + State: input.State, + Head: input.Head, + Base: input.Base, + Sort: input.Sort, + Direction: input.Direction, ListOptions: github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, @@ -1432,7 +1618,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - prs, resp, err := client.PullRequests.List(ctx, owner, repo, opts) + prs, resp, err := client.PullRequests.List(ctx, input.Owner, input.Repo, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list pull requests", @@ -1459,8 +1645,8 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool filtered := false var payload any = minimalPRs - if len(fields) > 0 { - filteredPRs, err := filterEachField(minimalPRs, fields) + if len(input.Fields) > 0 { + filteredPRs, err := filterEachField(minimalPRs, input.Fields) if err != nil { return utils.NewToolResultErrorFromErr("failed to filter pull requests", err), nil, nil } @@ -1478,9 +1664,11 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool result := utils.NewToolResultText(string(r)) // Pull request titles/bodies are user-authored (untrusted); // confidentiality follows repo visibility. - result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoUserContent) - return result, nil, nil - }) + result = attachRepoVisibilityIFCLabel(ctx, deps, client, input.Owner, input.Repo, result, ifc.LabelRepoUserContent) + return result, structuredListPullRequestsOutput(minimalPRs, input.Fields), nil + }, + normalizeTypedReadArguments(nil, false), + ) } // MergePullRequest creates a tool to merge a pull request. @@ -1513,16 +1701,21 @@ func MergePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool Description: "Merge method", Enum: []any{"merge", "squash", "rebase"}, }, + "expectedHeadSha": { + Type: "string", + Description: "The expected SHA of the pull request's HEAD ref", + }, }, Required: []string{"owner", "repo", "pullNumber"}, } - return NewTool( + return NewTool[MergePullRequestInput, *PullRequestMergeOutput]( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "merge_pull_request", - Description: t("TOOL_MERGE_PULL_REQUEST_DESCRIPTION", "Merge a pull request in a GitHub repository."), - Icons: octicons.Icons("git-merge"), + Name: "merge_pull_request", + OutputSchema: pullRequestMergeOutputSchema(), + Description: t("TOOL_MERGE_PULL_REQUEST_DESCRIPTION", "Merge a pull request in a GitHub repository."), + Icons: octicons.Icons("git-merge"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_MERGE_PULL_REQUEST_USER_TITLE", "Merge pull request"), ReadOnlyHint: false, @@ -1530,7 +1723,11 @@ func MergePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool InputSchema: schema, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input MergePullRequestInput) (*mcp.CallToolResult, *PullRequestMergeOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1555,9 +1752,14 @@ func MergePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + expectedHeadSHA, err := OptionalParam[string](args, "expectedHeadSha") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } options := &github.PullRequestOptions{ CommitTitle: commitTitle, + SHA: expectedHeadSHA, MergeMethod: mergeMethod, } @@ -1588,8 +1790,12 @@ func MergePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil - }) + output := &PullRequestMergeOutput{} + if result != nil { + output.Result = &PullRequestMergeResult{SHA: result.SHA, Merged: result.Merged, Message: result.Message} + } + return utils.NewToolResultText(string(r)), output, nil + }, normalizePullRequestArguments("merge")) } // SearchPullRequests creates a tool to search for pull requests. @@ -1640,7 +1846,7 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo ) WithPagination(schema) - return NewTool( + return NewTool[SearchIssuesInput, SearchIssuesOutput]( ToolsetMetadataPullRequests, mcp.Tool{ Name: "search_pull_requests", @@ -1652,16 +1858,14 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo InputSchema: schema, }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SearchIssuesInput) (*mcp.CallToolResult, SearchIssuesOutput, error) { options := []searchOption{ifcSearchPostProcessOption(ctx, deps)} - fields, err := OptionalStringArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - options = append(options, withFieldsFiltering(deps, "search_pull_requests", fields)) - result, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests", options...) - return result, nil, err - }) + options = append(options, withFieldsFiltering(deps, "search_pull_requests", input.Fields)) + result, response, err := searchHandler(ctx, deps.GetClient, input, "pr", "failed to search pull requests", options...) + return result, structuredSearchIssuesOutput(response, input.Fields), err + }, + normalizeTypedReadArguments(nil, false), + ) } // UpdatePullRequestBranch creates a tool to update a pull request branch with the latest changes from the base branch. @@ -1689,11 +1893,12 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser Required: []string{"owner", "repo", "pullNumber"}, } - return NewTool( + return NewTool[UpdatePullRequestBranchInput, *PullRequestBranchUpdateOutput]( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "update_pull_request_branch", - Description: t("TOOL_UPDATE_PULL_REQUEST_BRANCH_DESCRIPTION", "Update the branch of a pull request with the latest changes from the base branch."), + Name: "update_pull_request_branch", + OutputSchema: pullRequestBranchUpdateOutputSchema(), + Description: t("TOOL_UPDATE_PULL_REQUEST_BRANCH_DESCRIPTION", "Update the branch of a pull request with the latest changes from the base branch."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UPDATE_PULL_REQUEST_BRANCH_USER_TITLE", "Update pull request branch"), ReadOnlyHint: false, @@ -1701,7 +1906,11 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser InputSchema: schema, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input UpdatePullRequestBranchInput) (*mcp.CallToolResult, *PullRequestBranchUpdateOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1720,7 +1929,7 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser } opts := &github.PullRequestBranchUpdateOptions{} if expectedHeadSHA != "" { - opts.ExpectedHeadSHA = github.Ptr(expectedHeadSHA) + opts.ExpectedHeadSHA = new(expectedHeadSHA) } client, err := deps.GetClient(ctx) @@ -1732,7 +1941,8 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser // Check if it's an acceptedError. An acceptedError indicates that the update is in progress, // and it's not a real error. if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) { - return utils.NewToolResultText("Pull request branch update is in progress"), nil, nil + message := "Pull request branch update is in progress" + return utils.NewToolResultText(message), &PullRequestBranchUpdateOutput{Result: &PullRequestBranchUpdateResult{Message: &message}}, nil } return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update pull request branch", @@ -1755,22 +1965,43 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil - }) + output := &PullRequestBranchUpdateOutput{} + if result != nil { + output.Result = &PullRequestBranchUpdateResult{Message: result.Message} + } + return utils.NewToolResultText(string(r)), output, nil + }, normalizePullRequestArguments("branch")) } type PullRequestReviewWriteParams struct { - Method string - Owner string - Repo string - PullNumber int32 - Body string - Event string - CommitID *string - ThreadID string + Method string + Owner string + Repo string + PullNumber int32 + Body string + Event string + CommitID *string + ThreadID string + ResolutionReason *string } func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + return pullRequestReviewWrite(t, false, toolConfig{}) +} + +// PullRequestReviewWriteWithResolutionReason creates the feature-gated review write variant with resolution reasons. +func PullRequestReviewWriteWithResolutionReason(t translations.TranslationHelperFunc, opts ...ToolOption) inventory.ServerTool { + cfg := newToolConfig(opts) + st := pullRequestReviewWrite(t, true, cfg) + if cfg.hostType == utils.HostTypeGHES { + st.Enabled = func(context.Context) (bool, error) { return false, nil } + } + return st +} + +func pullRequestReviewWrite(t translations.TranslationHelperFunc, withResolutionReason bool, cfg toolConfig) inventory.ServerTool { + withResolutionReason = withResolutionReason && cfg.hostType != utils.HostTypeGHES + schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -1814,11 +2045,18 @@ func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.Serv }, Required: []string{"method", "owner", "repo", "pullNumber"}, } + if withResolutionReason { + schema.Properties["resolutionReason"] = &jsonschema.Schema{ + Type: "string", + Description: "Optional reason for resolving a Copilot code review thread: addressed, wont-fix, or invalid.", + } + } - st := NewTool( + st := NewTool[PullRequestReviewWriteInput, *RepositoryMessageOutput]( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "pull_request_review_write", + Name: "pull_request_review_write", + OutputSchema: pullRequestOutputSchema[RepositoryMessageOutput](), Description: t("TOOL_PULL_REQUEST_REVIEW_WRITE_DESCRIPTION", `Create and/or submit, delete review of a pull request. Available methods: @@ -1835,7 +2073,11 @@ Available methods: InputSchema: schema, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input PullRequestReviewWriteInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } var params PullRequestReviewWriteParams if err := mapstructure.WeakDecode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1847,27 +2089,53 @@ Available methods: return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } + var result *mcp.CallToolResult + switch params.Method { case "create": - result, err := CreatePullRequestReview(ctx, client, params) - return result, nil, err + result, err = CreatePullRequestReview(ctx, client, params) + return pullRequestMessageResult(result, err) case "submit_pending": - result, err := SubmitPendingPullRequestReview(ctx, client, params) - return result, nil, err + result, err = SubmitPendingPullRequestReview(ctx, client, params) + return pullRequestMessageResult(result, err) case "delete_pending": - result, err := DeletePendingPullRequestReview(ctx, client, params) - return result, nil, err + result, err = DeletePendingPullRequestReview(ctx, client, params) + return pullRequestMessageResult(result, err) case "resolve_thread": - result, err := ResolveReviewThread(ctx, client, params.ThreadID, true) - return result, nil, err + if !withResolutionReason { + result, err = ResolveReviewThread(ctx, client, params.ThreadID, true) + return pullRequestMessageResult(result, err) + } + result, err = ResolveReviewThreadWithReason(ctx, client, params.ThreadID, params.ResolutionReason, true) + return pullRequestMessageResult(result, err) case "unresolve_thread": - result, err := ResolveReviewThread(ctx, client, params.ThreadID, false) - return result, nil, err + result, err = ResolveReviewThread(ctx, client, params.ThreadID, false) + return pullRequestMessageResult(result, err) default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", params.Method)), nil, nil } - }) - st.FeatureFlagDisable = []string{FeatureFlagPullRequestsGranular} + }, normalizePullRequestReviewWriteArguments) + if withResolutionReason { + st.FeatureRule = inventory.NewFeatureRule( + []inventory.FeatureFlag{FeatureFlagThreadResolutionReason, inventory.FeatureFlag(FeatureFlagPullRequestsGranular)}, + func(featureAsBool inventory.FeatureResolver) bool { + return featureAsBool(FeatureFlagThreadResolutionReason) && + !featureAsBool(inventory.FeatureFlag(FeatureFlagPullRequestsGranular)) + }, + ) + } else { + if cfg.hostType == utils.HostTypeGHES { + st.FeatureRule = pullRequestsConsolidatedRule + } else { + st.FeatureRule = inventory.NewFeatureRule( + []inventory.FeatureFlag{FeatureFlagThreadResolutionReason, inventory.FeatureFlag(FeatureFlagPullRequestsGranular)}, + func(featureAsBool inventory.FeatureResolver) bool { + return !featureAsBool(FeatureFlagThreadResolutionReason) && + !featureAsBool(inventory.FeatureFlag(FeatureFlagPullRequestsGranular)) + }, + ) + } + } return st } @@ -1930,57 +2198,9 @@ func CreatePullRequestReview(ctx context.Context, client *githubv4.Client, param } func SubmitPendingPullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { - // 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 - } - - 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 + review, result := getPendingPullRequestReviewForViewer(ctx, client, params.Owner, params.Repo, params.PullNumber) + if result != nil { + return result, nil } // Prepare the mutation @@ -1996,7 +2216,7 @@ func SubmitPendingPullRequestReview(ctx context.Context, client *githubv4.Client ctx, &submitPullRequestReviewMutation, githubv4.SubmitPullRequestReviewInput{ - PullRequestReviewID: &review.ID, + PullRequestReviewID: review, Event: githubv4.PullRequestReviewEvent(params.Event), Body: newGQLStringlikePtr[githubv4.String](¶ms.Body), }, @@ -2015,57 +2235,9 @@ func SubmitPendingPullRequestReview(ctx context.Context, client *githubv4.Client } func DeletePendingPullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { - // 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 - } - - 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 + review, result := getPendingPullRequestReviewForViewer(ctx, client, params.Owner, params.Repo, params.PullNumber) + if result != nil { + return result, nil } // Prepare the mutation @@ -2081,7 +2253,7 @@ func DeletePendingPullRequestReview(ctx context.Context, client *githubv4.Client ctx, &deletePullRequestReviewMutation, githubv4.DeletePullRequestReviewInput{ - PullRequestReviewID: &review.ID, + PullRequestReviewID: review, }, nil, ); err != nil { @@ -2094,8 +2266,19 @@ func DeletePendingPullRequestReview(ctx context.Context, client *githubv4.Client return utils.NewToolResultText("pending pull request review successfully deleted"), nil } +// ResolveReviewThreadInput is the GraphQL input for resolving a review thread. +type ResolveReviewThreadInput struct { + ThreadID githubv4.ID `json:"threadId"` + ResolutionReason *githubv4.String `json:"resolutionReason,omitempty"` +} + // 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) { + return ResolveReviewThreadWithReason(ctx, client, threadID, nil, resolve) +} + +// ResolveReviewThreadWithReason resolves or unresolves a PR review thread with an optional resolution reason. +func ResolveReviewThreadWithReason(ctx context.Context, client *githubv4.Client, threadID string, resolutionReason *string, resolve bool) (*mcp.CallToolResult, error) { if threadID == "" { return utils.NewToolResultError("threadId is required for resolve_thread and unresolve_thread methods"), nil } @@ -2110,8 +2293,9 @@ func ResolveReviewThread(ctx context.Context, client *githubv4.Client, threadID } `graphql:"resolveReviewThread(input: $input)"` } - input := githubv4.ResolveReviewThreadInput{ - ThreadID: githubv4.ID(threadID), + input := ResolveReviewThreadInput{ + ThreadID: githubv4.ID(threadID), + ResolutionReason: newGQLStringlikePtr[githubv4.String](resolutionReason), } if err := client.Mutate(ctx, &mutation, input, nil); err != nil { @@ -2164,57 +2348,9 @@ type AddCommentToPendingReviewParams struct { // 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 + review, result := getPendingPullRequestReviewForViewer(ctx, client, params.Owner, params.Repo, params.PullNumber) + if result != nil { + return result, nil } // Create a new review thread comment on the review. @@ -2230,14 +2366,14 @@ func AddCommentToPendingReviewCall(ctx context.Context, client *githubv4.Client, ctx, &addPullRequestReviewThreadMutation, githubv4.AddPullRequestReviewThreadInput{ - Path: githubv4.String(params.Path), + Path: newGQLStringlikePtr[githubv4.String](¶ms.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, + PullRequestReviewID: review, }, nil, ); err != nil { @@ -2255,6 +2391,100 @@ func AddCommentToPendingReviewCall(ctx context.Context, client *githubv4.Client, return utils.NewToolResultText("pull request review comment successfully added to pending review"), nil } +type pendingReviewAuthor struct { + Bot struct { + ID githubv4.ID `graphql:"botId: id"` + } `graphql:"... on Bot"` + EnterpriseUserAccount struct { + ID githubv4.ID `graphql:"enterpriseUserAccountId: id"` + } `graphql:"... on EnterpriseUserAccount"` + Mannequin struct { + ID githubv4.ID `graphql:"mannequinId: id"` + } `graphql:"... on Mannequin"` + Organization struct { + ID githubv4.ID `graphql:"organizationId: id"` + } `graphql:"... on Organization"` + User struct { + ID githubv4.ID `graphql:"userId: id"` + } `graphql:"... on User"` +} + +func (a pendingReviewAuthor) id() githubv4.ID { + for _, id := range []githubv4.ID{ + a.Bot.ID, + a.EnterpriseUserAccount.ID, + a.Mannequin.ID, + a.Organization.ID, + a.User.ID, + } { + if id != nil { + return id + } + } + return nil +} + +func getPendingPullRequestReviewForViewer(ctx context.Context, client *githubv4.Client, owner, repo string, pullNumber int32) (*githubv4.ID, *mcp.CallToolResult) { + var getViewerQuery struct { + Viewer struct { + ID githubv4.ID + } + } + + if err := client.Query(ctx, &getViewerQuery, nil); err != nil { + return nil, ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to get current user", + err, + ) + } + + vars := map[string]any{ + "after": (*githubv4.String)(nil), + "owner": githubv4.String(owner), + "name": githubv4.String(repo), + "prNum": githubv4.Int(pullNumber), + "states": []githubv4.PullRequestReviewState{githubv4.PullRequestReviewStatePending}, + } + + for { + var getPendingReviewsQuery struct { + Repository struct { + PullRequest struct { + Reviews struct { + Nodes []struct { + ID githubv4.ID + Author pendingReviewAuthor + } + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + } `graphql:"reviews(first: 100, after: $after, states: $states)"` + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + if err := client.Query(ctx, &getPendingReviewsQuery, vars); err != nil { + return nil, ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to get pending pull request reviews", + err, + ) + } + + for _, review := range getPendingReviewsQuery.Repository.PullRequest.Reviews.Nodes { + if review.Author.id() == getViewerQuery.Viewer.ID { + reviewID := review.ID + return &reviewID, nil + } + } + + if !getPendingReviewsQuery.Repository.PullRequest.Reviews.PageInfo.HasNextPage { + return nil, utils.NewToolResultError("No pending review found for the viewer") + } + vars["after"] = githubv4.NewString(getPendingReviewsQuery.Repository.PullRequest.Reviews.PageInfo.EndCursor) + } +} + // AddCommentToPendingReview creates a tool to add a comment to a pull request review. func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ @@ -2316,11 +2546,12 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S Required: []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"}, } - st := NewTool( + st := NewTool[AddCommentToPendingReviewInput, *RepositoryMessageOutput]( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "add_comment_to_pending_review", - Description: t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_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)."), + Name: "add_comment_to_pending_review", + OutputSchema: pullRequestOutputSchema[RepositoryMessageOutput](), + Description: t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_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)."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_USER_TITLE", "Add review comment to the requester's latest pending pull request review"), ReadOnlyHint: false, @@ -2328,7 +2559,11 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S InputSchema: schema, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input AddCommentToPendingReviewInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -2398,9 +2633,9 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S StartLine: startLinePtr, StartSide: startSidePtr, }) - return result, nil, err - }) - st.FeatureFlagDisable = []string{FeatureFlagPullRequestsGranular} + return pullRequestMessageResult(result, err) + }, normalizePullRequestArguments("pending_comment")) + st.FeatureRule = pullRequestsConsolidatedRule return st } diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go index 5da812f271..423a53114d 100644 --- a/pkg/github/pullrequests_granular.go +++ b/pkg/github/pullrequests_granular.go @@ -7,24 +7,50 @@ import ( "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/v89/github" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + gogithub "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) +func newGranularPullRequestTool[In, Out any]( + toolset inventory.ToolsetMetadata, + tool mcp.Tool, + scopeAccess inventory.ScopeAccess, + handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error), + inputNormalizers ...inventory.InputNormalizer, +) inventory.ServerTool { + inputSchema, ok := tool.InputSchema.(*jsonschema.Schema) + if !ok { + panic("granular pull request tool input schema must be a JSON Schema") + } + return NewToolWithSchemaOptions( + toolset, + tool, + scopeAccess, + inventory.TypedSchemaOptions{ + ValidationInputSchema: granularPullRequestValidationSchema(inputSchema), + }, + handler, + inputNormalizers..., + ) +} + // prUpdateTool is a helper to create single-field pull request update tools via REST. -func prUpdateTool( +func prUpdateTool[In interface { + pullRequestCoordinate() GranularPullRequestCoordinate +}]( t translations.TranslationHelperFunc, name, description, title string, extraProps map[string]*jsonschema.Schema, extraRequired []string, - buildRequest func(args map[string]any) (*gogithub.PullRequest, error), + buildRequest func(args In) *gogithub.PullRequest, + normalizer inventory.InputNormalizer, ) inventory.ServerTool { props := map[string]*jsonschema.Schema{ "owner": { @@ -38,14 +64,14 @@ func prUpdateTool( "pullNumber": { Type: "number", Description: "The pull request number", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, } maps.Copy(props, extraProps) required := append([]string{"owner", "repo", "pullNumber"}, extraRequired...) - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: name, @@ -53,8 +79,8 @@ func prUpdateTool( Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_"+strings.ToUpper(name)+"_USER_TITLE", title), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -63,24 +89,10 @@ func prUpdateTool( }, }, scopes.RequireAll(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 - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args In) (*mcp.CallToolResult, *MinimalResponse, error) { + coordinate := args.pullRequestCoordinate() + owner, repo, pullNumber := coordinate.Owner, coordinate.Repo, coordinate.PullNumber + prReq := buildRequest(args) client, err := deps.GetClient(ctx) if err != nil { @@ -93,17 +105,19 @@ func prUpdateTool( } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(MinimalResponse{ + output := &MinimalResponse{ ID: fmt.Sprintf("%d", pr.GetID()), URL: pr.GetHTMLURL(), - }) + } + r, err := json.Marshal(output) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), output, nil }, + normalizer, ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } @@ -117,13 +131,10 @@ func GranularUpdatePullRequestTitle(t translations.TranslationHelperFunc) invent "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 + func(args GranularPullRequestTitleInput) *gogithub.PullRequest { + return &gogithub.PullRequest{Title: &args.Title} }, + normalizeGranularPullRequestArguments("title"), ) } @@ -137,13 +148,10 @@ func GranularUpdatePullRequestBody(t translations.TranslationHelperFunc) invento "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 + func(args GranularPullRequestBodyInput) *gogithub.PullRequest { + return &gogithub.PullRequest{Body: &args.Body} }, + normalizeGranularPullRequestArguments("body"), ) } @@ -157,23 +165,21 @@ func GranularUpdatePullRequestState(t translations.TranslationHelperFunc) invent "state": { Type: "string", Description: "The new state for the pull request", - Enum: []any{"open", "closed"}, + Enum: GranularPullRequestState("").JSONSchema().Enum, }, }, []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 + func(args GranularPullRequestStateInput) *gogithub.PullRequest { + state := string(args.State) + return &gogithub.PullRequest{State: &state} }, + normalizeGranularPullRequestArguments("state"), ) } // GranularUpdatePullRequestDraftState creates a tool to toggle draft state. func GranularUpdatePullRequestDraftState(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "update_pull_request_draft_state", @@ -181,42 +187,23 @@ func GranularUpdatePullRequestDraftState(t translations.TranslationHelperFunc) i 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), + DestructiveHint: new(false), + OpenWorldHint: new(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)}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(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.RequireAll(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 - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularPullRequestDraftInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner, repo, pullNumber, draft := args.Owner, args.Repo, args.PullNumber, args.Draft gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -253,7 +240,7 @@ func GranularUpdatePullRequestDraftState(t translations.TranslationHelperFunc) i }, 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 + return pullRequestMessageResult(utils.NewToolResultText("pull request converted to draft"), nil) } var mutation struct { @@ -269,16 +256,17 @@ func GranularUpdatePullRequestDraftState(t translations.TranslationHelperFunc) i }, 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 + return pullRequestMessageResult(utils.NewToolResultText("pull request marked as ready for review"), nil) }, + normalizeGranularPullRequestArguments("draft"), ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } // GranularRequestPullRequestReviewers creates a tool to request reviewers. func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "request_pull_request_reviewers", @@ -286,15 +274,15 @@ func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) i 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), + DestructiveHint: new(false), + OpenWorldHint: new(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)}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(1.0)}, "reviewers": { Type: "array", Description: "GitHub usernames or ORG/team-slug team reviewers to request reviews from", @@ -305,27 +293,9 @@ func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) i }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - pullNumber, err := RequiredInt(args, "pullNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - reviewers, err := OptionalStringArrayParam(args, "reviewers") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if len(reviewers) == 0 { - return utils.NewToolResultError("missing required parameter: reviewers"), nil, nil - } - userReviewers, teamReviewers := splitPullRequestReviewers(reviewers) + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularPullRequestReviewersInput) (*mcp.CallToolResult, *MinimalResponse, error) { + owner, repo, pullNumber := args.Owner, args.Repo, args.PullNumber + userReviewers, teamReviewers := splitPullRequestReviewers(args.Reviewers) client, err := deps.GetClient(ctx) if err != nil { @@ -341,17 +311,19 @@ func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) i } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(MinimalResponse{ + output := &MinimalResponse{ ID: fmt.Sprintf("%d", pr.GetID()), URL: pr.GetHTMLURL(), - }) + } + r, err := json.Marshal(output) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), output, nil }, + normalizeGranularPullRequestArguments("reviewers"), ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } @@ -373,7 +345,7 @@ func splitPullRequestReviewers(reviewers []string) ([]string, []string) { // GranularCreatePullRequestReview creates a tool to create a PR review. func GranularCreatePullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "create_pull_request_review", @@ -381,39 +353,26 @@ func GranularCreatePullRequestReview(t translations.TranslationHelperFunc) inven 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), + DestructiveHint: new(false), + OpenWorldHint: new(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)}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(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"}}, + "event": {Type: "string", Description: "The review action to perform. If omitted, creates a pending review.", Enum: GranularPullRequestReviewEvent("").JSONSchema().Enum}, "commitID": {Type: "string", Description: "The SHA of the commit to review (optional, defaults to latest)"}, }, Required: []string{"owner", "repo", "pullNumber"}, }, }, scopes.RequireAll(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") + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularCreatePullRequestReviewInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner, repo, pullNumber := args.Owner, args.Repo, args.PullNumber + body, event, commitID := args.Body, string(args.Event), args.CommitID gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -433,16 +392,17 @@ func GranularCreatePullRequestReview(t translations.TranslationHelperFunc) inven Event: event, CommitID: commitIDPtr, }) - return result, nil, err + return pullRequestMessageResult(result, err) }, + normalizeGranularPullRequestArguments("create_review"), ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } // GranularSubmitPendingPullRequestReview creates a tool to submit a pending review. func GranularSubmitPendingPullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "submit_pending_pull_request_review", @@ -450,40 +410,24 @@ func GranularSubmitPendingPullRequestReview(t translations.TranslationHelperFunc 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), + DestructiveHint: new(false), + OpenWorldHint: new(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"}}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(1.0)}, + "event": {Type: "string", Description: "The review action to perform", Enum: GranularPullRequestReviewEvent("").JSONSchema().Enum}, "body": {Type: "string", Description: "The review body text (optional)"}, }, Required: []string{"owner", "repo", "pullNumber", "event"}, }, }, scopes.RequireAll(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") + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularSubmitPullRequestReviewInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner, repo, pullNumber, event, body := args.Owner, args.Repo, args.PullNumber, string(args.Event), args.Body gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -497,16 +441,17 @@ func GranularSubmitPendingPullRequestReview(t translations.TranslationHelperFunc Event: event, Body: body, }) - return result, nil, err + return pullRequestMessageResult(result, err) }, + normalizeGranularPullRequestArguments("submit_review"), ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } // GranularDeletePendingPullRequestReview creates a tool to delete a pending review. func GranularDeletePendingPullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "delete_pending_pull_request_review", @@ -514,33 +459,22 @@ func GranularDeletePendingPullRequestReview(t translations.TranslationHelperFunc 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), + DestructiveHint: new(true), + OpenWorldHint: new(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)}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(1.0)}, }, Required: []string{"owner", "repo", "pullNumber"}, }, }, scopes.RequireAll(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 - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularPullRequestCoordinate) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner, repo, pullNumber := args.Owner, args.Repo, args.PullNumber gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -552,16 +486,17 @@ func GranularDeletePendingPullRequestReview(t translations.TranslationHelperFunc Repo: repo, PullNumber: int32(pullNumber), // #nosec G115 - PR numbers are always small positive integers }) - return result, nil, err + return pullRequestMessageResult(result, err) }, + normalizeGranularPullRequestArguments("delete_review"), ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } // GranularAddPullRequestReviewComment creates a tool to add a review comment. func GranularAddPullRequestReviewComment(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "add_pull_request_review_comment", @@ -569,62 +504,29 @@ func GranularAddPullRequestReviewComment(t translations.TranslationHelperFunc) i 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), + DestructiveHint: new(false), + OpenWorldHint: new(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)}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(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"}}, + "subjectType": {Type: "string", Description: "The subject type of the comment", Enum: GranularPullRequestCommentSubject("").JSONSchema().Enum}, "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"}}, + "side": {Type: "string", Description: "The side of the diff to comment on (optional)", Enum: GranularPullRequestDiffSide("").JSONSchema().Enum}, "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"}}, + "startSide": {Type: "string", Description: "The start side of a multi-line comment (optional)", Enum: GranularPullRequestDiffSide("").JSONSchema().Enum}, }, Required: []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"}, }, }, scopes.RequireAll(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") + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularPullRequestReviewCommentInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner, repo, pullNumber := args.Owner, args.Repo, args.PullNumber gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -633,21 +535,23 @@ func GranularAddPullRequestReviewComment(t translations.TranslationHelperFunc) i // Convert optional int params to *int32 for the helper var linePtr, startLinePtr *int32 - if line != 0 { - l := int32(line) // #nosec G115 + if args.Line != nil && *args.Line != 0 { + l := int32(*args.Line) // #nosec G115 linePtr = &l } - if startLine != 0 { - sl := int32(startLine) // #nosec G115 + if args.StartLine != nil && *args.StartLine != 0 { + sl := int32(*args.StartLine) // #nosec G115 startLinePtr = &sl } // Convert optional string params: pass nil (not empty string) when absent var sidePtr, startSidePtr *string - if side != "" { + if args.Side != nil && *args.Side != "" { + side := string(*args.Side) sidePtr = &side } - if startSide != "" { + if args.StartSide != nil && *args.StartSide != "" { + startSide := string(*args.StartSide) startSidePtr = &startSide } @@ -655,24 +559,54 @@ func GranularAddPullRequestReviewComment(t translations.TranslationHelperFunc) i Owner: owner, Repo: repo, PullNumber: int32(pullNumber), // #nosec G115 - PR numbers are always small positive integers - Path: path, - Body: body, - SubjectType: subjectType, + Path: args.Path, + Body: args.Body, + SubjectType: string(args.SubjectType), Line: linePtr, Side: sidePtr, StartLine: startLinePtr, StartSide: startSidePtr, }) - return result, nil, err + return pullRequestMessageResult(result, err) }, + normalizeGranularPullRequestArguments("comment"), ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } // GranularResolveReviewThread creates a tool to resolve a review thread. func GranularResolveReviewThread(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + return granularResolveReviewThread(t, false, toolConfig{}) +} + +// GranularResolveReviewThreadWithResolutionReason creates the feature-gated variant with resolution reasons. +func GranularResolveReviewThreadWithResolutionReason(t translations.TranslationHelperFunc, opts ...ToolOption) inventory.ServerTool { + cfg := newToolConfig(opts) + st := granularResolveReviewThread(t, true, cfg) + if cfg.hostType == utils.HostTypeGHES { + st.Enabled = func(context.Context) (bool, error) { return false, nil } + } + return st +} + +func granularResolveReviewThread(t translations.TranslationHelperFunc, withResolutionReason bool, cfg toolConfig) inventory.ServerTool { + withResolutionReason = withResolutionReason && cfg.hostType != utils.HostTypeGHES + + properties := map[string]*jsonschema.Schema{ + "threadID": { + Type: "string", + Description: "The node ID of the review thread to resolve (e.g., PRRT_kwDOxxx)", + }, + } + if withResolutionReason { + properties["resolutionReason"] = &jsonschema.Schema{ + Type: "string", + Description: "Optional reason for resolving a Copilot code review thread: addressed, wont-fix, or invalid.", + } + } + + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "resolve_review_thread", @@ -680,25 +614,21 @@ func GranularResolveReviewThread(t translations.TranslationHelperFunc) inventory Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_RESOLVE_REVIEW_THREAD_USER_TITLE", "Resolve Review Thread"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(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"}, + Type: "object", + Properties: properties, + Required: []string{"threadID"}, }, }, scopes.RequireAll(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularResolveReviewThreadInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + threadID := args.ThreadID + var resolutionReasonPtr *string + if withResolutionReason { + resolutionReasonPtr = args.ResolutionReason } gqlClient, err := deps.GetGQLClient(ctx) @@ -706,17 +636,41 @@ func GranularResolveReviewThread(t translations.TranslationHelperFunc) inventory return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil } - result, err := ResolveReviewThread(ctx, gqlClient, threadID, true) - return result, nil, err + if !withResolutionReason { + result, err := ResolveReviewThread(ctx, gqlClient, threadID, true) + return pullRequestMessageResult(result, err) + } + result, err := ResolveReviewThreadWithReason(ctx, gqlClient, threadID, resolutionReasonPtr, true) + return pullRequestMessageResult(result, err) }, + normalizeGranularResolveReviewThreadArguments(withResolutionReason), ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + switch { + case withResolutionReason: + st.FeatureRule = inventory.NewFeatureRule( + []inventory.FeatureFlag{inventory.FeatureFlag(FeatureFlagPullRequestsGranular), FeatureFlagThreadResolutionReason}, + func(featureAsBool inventory.FeatureResolver) bool { + return featureAsBool(inventory.FeatureFlag(FeatureFlagPullRequestsGranular)) && + featureAsBool(FeatureFlagThreadResolutionReason) + }, + ) + case cfg.hostType == utils.HostTypeGHES: + st.FeatureRule = pullRequestsGranularFeatureRule + default: + st.FeatureRule = inventory.NewFeatureRule( + []inventory.FeatureFlag{inventory.FeatureFlag(FeatureFlagPullRequestsGranular), FeatureFlagThreadResolutionReason}, + func(featureAsBool inventory.FeatureResolver) bool { + return featureAsBool(inventory.FeatureFlag(FeatureFlagPullRequestsGranular)) && + !featureAsBool(FeatureFlagThreadResolutionReason) + }, + ) + } return st } // GranularUnresolveReviewThread creates a tool to unresolve a review thread. func GranularUnresolveReviewThread(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "unresolve_review_thread", @@ -724,8 +678,8 @@ func GranularUnresolveReviewThread(t translations.TranslationHelperFunc) invento Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UNRESOLVE_REVIEW_THREAD_USER_TITLE", "Unresolve Review Thread"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -739,11 +693,8 @@ func GranularUnresolveReviewThread(t translations.TranslationHelperFunc) invento }, }, scopes.RequireAll(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 - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularReviewThreadInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + threadID := args.ThreadID gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -751,25 +702,27 @@ func GranularUnresolveReviewThread(t translations.TranslationHelperFunc) invento } result, err := ResolveReviewThread(ctx, gqlClient, threadID, false) - return result, nil, err + return pullRequestMessageResult(result, err) }, + normalizeGranularPullRequestArguments("unresolve"), ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } // GranularAddPullRequestReviewCommentReaction adds a reaction to a pull request review comment. func GranularAddPullRequestReviewCommentReaction(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "add_pull_request_review_comment_reaction", - Description: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_REACTION_DESCRIPTION", "Add a reaction to a pull request review comment."), + Name: "add_pull_request_review_comment_reaction", + OutputSchema: minimalPullRequestCommentReactionSchema(), + Description: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_REACTION_DESCRIPTION", "Add a reaction to a pull request review comment."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_REACTION_USER_TITLE", "Add Pull Request Review Comment Reaction"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -785,57 +738,127 @@ func GranularAddPullRequestReviewCommentReaction(t translations.TranslationHelpe "comment_id": { Type: "number", Description: "The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "content": { Type: "string", Description: "The emoji reaction type", - Enum: []any{"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"}, + Enum: PullRequestCommentReactionType("").JSONSchema().Enum, }, }, Required: []string{"owner", "repo", "comment_id", "content"}, }, }, scopes.RequireAll(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 - } - commentID, err := RequiredBigInt(args, "comment_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - content, err := RequiredParam[string](args, "content") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularAddPullRequestCommentReactionInput) (*mcp.CallToolResult, *MinimalPullRequestCommentReaction, error) { + owner, repo, commentID, content := args.Owner, args.Repo, args.CommentID, args.Content client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - reaction, resp, err := client.Reactions.CreatePullRequestCommentReaction(ctx, owner, repo, commentID, content) + reaction, resp, err := client.Reactions.CreatePullRequestCommentReaction(ctx, owner, repo, commentID, string(content)) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reaction to pull request review comment", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(MinimalResponse{ + output := &MinimalResponse{ ID: fmt.Sprintf("%d", reaction.GetID()), URL: fmt.Sprintf("%srepos/%s/%s/pulls/comments/%d/reactions/%d", client.BaseURL(), owner, repo, commentID, reaction.GetID()), - }) + } + r, err := json.Marshal(output) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), output, nil }, + normalizeGranularPullRequestArguments("add_reaction"), ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } + +// GranularRemovePullRequestReviewCommentReaction removes a reaction from a pull request review comment. +func GranularRemovePullRequestReviewCommentReaction(t translations.TranslationHelperFunc) inventory.ServerTool { + st := newGranularPullRequestTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "remove_pull_request_review_comment_reaction", + Description: t("TOOL_REMOVE_PULL_REQUEST_REVIEW_COMMENT_REACTION_DESCRIPTION", "Remove a reaction from a pull request review comment."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_REMOVE_PULL_REQUEST_REVIEW_COMMENT_REACTION_USER_TITLE", "Remove Pull Request Review Comment Reaction"), + ReadOnlyHint: false, + DestructiveHint: new(true), + OpenWorldHint: new(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", + }, + "comment_id": { + Type: "number", + Description: "The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", + Minimum: new(1.0), + }, + "reaction_id": { + Type: "number", + Description: "The reaction ID to remove", + Minimum: new(1.0), + }, + }, + Required: []string{"owner", "repo", "comment_id", "reaction_id"}, + }, + }, + scopes.RequireAll(scopes.Repo), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularRemovePullRequestCommentReactionInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner, repo, commentID, reactionID := args.Owner, args.Repo, args.CommentID, args.ReactionID + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + resp, err := client.Reactions.DeletePullRequestCommentReaction(ctx, owner, repo, commentID, reactionID) + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to remove reaction from pull request review comment", resp, err), nil, nil + } + + return pullRequestMessageResult(utils.NewToolResultText("reaction successfully removed from pull request review comment"), nil) + }, + normalizeGranularPullRequestArguments("remove_reaction"), + ) + st.FeatureRule = pullRequestsGranularFeatureRule + return st +} + +// GranularHidePullRequestReviewComment hides (minimizes) an inline pull request review comment. +func GranularHidePullRequestReviewComment(t translations.TranslationHelperFunc) inventory.ServerTool { + return commentVisibilityTool(t, pullRequestReviewCommentVisibilityTarget, true) +} + +// GranularUnhidePullRequestReviewComment unhides (unminimizes) an inline pull request review comment. +func GranularUnhidePullRequestReviewComment(t translations.TranslationHelperFunc) inventory.ServerTool { + return commentVisibilityTool(t, pullRequestReviewCommentVisibilityTarget, false) +} + +// GranularHidePullRequestReview hides (minimizes) the body of a pull request review. +func GranularHidePullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool { + return commentVisibilityTool(t, pullRequestReviewVisibilityTarget, true) +} + +// GranularUnhidePullRequestReview unhides (unminimizes) the body of a pull request review. +func GranularUnhidePullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool { + return commentVisibilityTool(t, pullRequestReviewVisibilityTarget, false) +} diff --git a/pkg/github/pullrequests_sha_pinning_test.go b/pkg/github/pullrequests_sha_pinning_test.go new file mode 100644 index 0000000000..0995855f87 --- /dev/null +++ b/pkg/github/pullrequests_sha_pinning_test.go @@ -0,0 +1,93 @@ +package github + +import ( + "context" + "net/http" + "testing" + + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_MergePullRequestSHAPinning(t *testing.T) { + serverTool := MergePullRequest(translations.NullTranslationHelper) + + const pinnedSHA = "0123456789abcdef0123456789abcdef01234567" + + mockMergeResult := &github.PullRequestMergeResult{ + SHA: new("merge-result-sha"), + Merged: new(true), + Message: new("Pull Request successfully merged"), + } + + t.Run("expected head SHA is sent as REST sha", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsMergeByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ + "merge_method": "merge", + "sha": pinnedSHA, + }).andThen( + mockResponse(t, http.StatusOK, mockMergeResult), + ), + })) + + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "merge_method": "merge", + "expectedHeadSha": pinnedSHA, + }) + + result, err := handler( + ContextWithDeps(context.Background(), deps), + &request, + ) + + require.NoError(t, err) + require.False(t, result.IsError) + }) + + t.Run("head mismatch fails closed without retry", func(t *testing.T) { + calls := 0 + + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsMergeByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ + "merge_method": "merge", + "sha": pinnedSHA, + }).andThen(func(w http.ResponseWriter, _ *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte( + `{"message":"Head branch was modified. Review and try the merge again."}`, + )) + }), + })) + + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "merge_method": "merge", + "expectedHeadSha": pinnedSHA, + }) + + result, err := handler( + ContextWithDeps(context.Background(), deps), + &request, + ) + + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "Head branch was modified") + assert.Equal(t, 1, calls, "merge must not retry after a head-SHA mismatch") + }) +} diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 03ec851cf4..d0f8887e6a 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -9,10 +9,10 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -36,20 +36,20 @@ func Test_GetPullRequest(t *testing.T) { // 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"), + Number: new(42), + Title: new("Test PR"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), Head: &github.PullRequestBranch{ - SHA: github.Ptr("abcd1234"), - Ref: github.Ptr("feature-branch"), + SHA: new("abcd1234"), + Ref: new("feature-branch"), }, Base: &github.PullRequestBranch{ - Ref: github.Ptr("main"), + Ref: new("main"), }, - Body: github.Ptr("This is a test PR"), + Body: new("This is a test PR"), User: &github.User{ - Login: github.Ptr("testuser"), + Login: new("testuser"), }, } @@ -203,32 +203,32 @@ func Test_UpdatePullRequest(t *testing.T) { // Setup mock PR for success case mockUpdatedPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Updated Test PR Title"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - Body: github.Ptr("Updated test PR body."), - MaintainerCanModify: github.Ptr(false), - Draft: github.Ptr(false), + Number: new(42), + Title: new("Updated Test PR Title"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), + Body: new("Updated test PR body."), + MaintainerCanModify: new(false), + Draft: new(false), Base: &github.PullRequestBranch{ - Ref: github.Ptr("develop"), + Ref: new("develop"), }, } mockClosedPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - State: github.Ptr("closed"), // State updated + Number: new(42), + Title: new("Test PR"), + State: new("closed"), // State updated } // Mock PR for when there are no updates but we still need a response mockPRWithReviewers := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - State: github.Ptr("open"), + Number: new(42), + Title: new("Test PR"), + State: new("open"), RequestedReviewers: []*github.User{ - {Login: github.Ptr("reviewer1")}, - {Login: github.Ptr("reviewer2")}, + {Login: new("reviewer1")}, + {Login: new("reviewer2")}, }, } @@ -430,15 +430,15 @@ func Test_UpdatePullRequest(t *testing.T) { func Test_UpdatePullRequest_Draft(t *testing.T) { // Setup mock PR for success case mockUpdatedPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR Title"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - Body: github.Ptr("Test PR body."), - MaintainerCanModify: github.Ptr(false), - Draft: github.Ptr(false), // Updated to ready for review + Number: new(42), + Title: new("Test PR Title"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), + Body: new("Test PR body."), + MaintainerCanModify: new(false), + Draft: new(false), // Updated to ready for review Base: &github.PullRequestBranch{ - Ref: github.Ptr("main"), + Ref: new("main"), }, } @@ -635,16 +635,16 @@ func Test_ListPullRequests(t *testing.T) { // Setup mock PRs for success case mockPRs := []*github.PullRequest{ { - Number: github.Ptr(42), - Title: github.Ptr("First PR"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Number: new(42), + Title: new("First PR"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), }, { - Number: github.Ptr(43), - Title: github.Ptr("Second PR"), - State: github.Ptr("closed"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/43"), + Number: new(43), + Title: new("Second PR"), + State: new("closed"), + HTMLURL: new("https://github.com/owner/repo/pull/43"), }, } @@ -760,13 +760,14 @@ func Test_MergePullRequest(t *testing.T) { assert.Contains(t, schema.Properties, "commit_title") assert.Contains(t, schema.Properties, "commit_message") assert.Contains(t, schema.Properties, "merge_method") + assert.Contains(t, schema.Properties, "expectedHeadSha") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber"}) // Setup mock merge result for success case mockMergeResult := &github.PullRequestMergeResult{ - Merged: github.Ptr(true), - Message: github.Ptr("Pull Request successfully merged"), - SHA: github.Ptr("abcd1234efgh5678"), + Merged: new(true), + Message: new("Pull Request successfully merged"), + SHA: new("abcd1234efgh5678"), } tests := []struct { @@ -778,7 +779,7 @@ func Test_MergePullRequest(t *testing.T) { expectedErrMsg string }{ { - name: "successful merge", + name: "successful merge without expected head SHA", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PutReposPullsMergeByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ "commit_title": "Merge PR #42", @@ -878,29 +879,29 @@ func Test_SearchPullRequests(t *testing.T) { assert.ElementsMatch(t, schema.Required, []string{"query"}) mockSearchResult := &github.IssuesSearchResult{ - Total: github.Ptr(2), - IncompleteResults: github.Ptr(false), + Total: new(2), + IncompleteResults: new(false), Issues: []*github.Issue{ { - Number: github.Ptr(42), - Title: github.Ptr("Test PR 1"), - Body: github.Ptr("Updated tests."), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/1"), - Comments: github.Ptr(5), + Number: new(42), + Title: new("Test PR 1"), + Body: new("Updated tests."), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/1"), + Comments: new(5), User: &github.User{ - Login: github.Ptr("user1"), + Login: new("user1"), }, }, { - Number: github.Ptr(43), - Title: github.Ptr("Test PR 2"), - Body: github.Ptr("Updated build scripts."), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/2"), - Comments: github.Ptr(3), + Number: new(43), + Title: new("Test PR 2"), + Body: new("Updated build scripts."), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/2"), + Comments: new(3), User: &github.User{ - Login: github.Ptr("user2"), + Login: new("user2"), }, }, }, @@ -1167,20 +1168,20 @@ func Test_GetPullRequestFiles(t *testing.T) { // Setup mock PR files for success case mockFiles := []*github.CommitFile{ { - Filename: github.Ptr("file1.go"), - Status: github.Ptr("modified"), - Additions: github.Ptr(10), - Deletions: github.Ptr(5), - Changes: github.Ptr(15), - Patch: github.Ptr("@@ -1,5 +1,10 @@"), + Filename: new("file1.go"), + Status: new("modified"), + Additions: new(10), + Deletions: new(5), + Changes: new(15), + Patch: new("@@ -1,5 +1,10 @@"), }, { - Filename: github.Ptr("file2.go"), - Status: github.Ptr("added"), - Additions: github.Ptr(20), - Deletions: github.Ptr(0), - Changes: github.Ptr(20), - Patch: github.Ptr("@@ -0,0 +1,20 @@"), + Filename: new("file2.go"), + Status: new("added"), + Additions: new(20), + Deletions: new(0), + Changes: new(20), + Patch: new("@@ -0,0 +1,20 @@"), }, } @@ -1260,8 +1261,8 @@ func Test_GetPullRequestFiles(t *testing.T) { name: "lockdown enabled - author lacks push access", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{ - Number: github.Ptr(42), - User: &github.User{Login: github.Ptr("reader")}, + Number: new(42), + User: &github.User{Login: new("reader")}, }), }), requestArgs: map[string]any{ @@ -1279,8 +1280,8 @@ func Test_GetPullRequestFiles(t *testing.T) { name: "lockdown enabled - author has push access", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{ - Number: github.Ptr(42), - User: &github.User{Login: github.Ptr("writer")}, + Number: new(42), + User: &github.User{Login: new("writer")}, }), GetReposPullsFilesByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockFiles), }), @@ -1390,39 +1391,39 @@ func Test_GetPullRequestCommits(t *testing.T) { authorDate := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) mockCommits := []*github.RepositoryCommit{ { - SHA: github.Ptr("abc123def456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + SHA: new("abc123def456"), + HTMLURL: new("https://github.com/owner/repo/commit/abc123def456"), Commit: &github.Commit{ - Message: github.Ptr("feat: add commit listing"), + Message: new("feat: add commit listing"), Author: &github.CommitAuthor{ - Name: github.Ptr("Test User"), - Email: github.Ptr("test@example.com"), + Name: new("Test User"), + Email: new("test@example.com"), Date: &github.Timestamp{Time: authorDate}, }, Committer: &github.CommitAuthor{ - Name: github.Ptr("Merge Bot"), - Email: github.Ptr("merge@example.com"), + Name: new("Merge Bot"), + Email: new("merge@example.com"), Date: &github.Timestamp{Time: authorDate.Add(30 * time.Minute)}, }, }, Author: &github.User{ - Login: github.Ptr("test-user"), - ID: github.Ptr(int64(12345)), - HTMLURL: github.Ptr("https://github.com/test-user"), - AvatarURL: github.Ptr("https://github.com/test-user.png"), + Login: new("test-user"), + ID: new(int64(12345)), + HTMLURL: new("https://github.com/test-user"), + AvatarURL: new("https://github.com/test-user.png"), }, Committer: &github.User{ - Login: github.Ptr("merge-bot"), - ID: github.Ptr(int64(67890)), - HTMLURL: github.Ptr("https://github.com/merge-bot"), - AvatarURL: github.Ptr("https://github.com/merge-bot.png"), + Login: new("merge-bot"), + ID: new(int64(67890)), + HTMLURL: new("https://github.com/merge-bot"), + AvatarURL: new("https://github.com/merge-bot.png"), }, }, { - SHA: github.Ptr("def456abc789"), - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), + SHA: new("def456abc789"), + HTMLURL: new("https://github.com/owner/repo/commit/def456abc789"), Commit: &github.Commit{ - Message: github.Ptr("fix: handle pagination"), + Message: new("fix: handle pagination"), }, }, } @@ -1503,8 +1504,8 @@ func Test_GetPullRequestCommits(t *testing.T) { name: "lockdown enabled - author lacks push access", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{ - Number: github.Ptr(42), - User: &github.User{Login: github.Ptr("reader")}, + Number: new(42), + User: &github.User{Login: new("reader")}, }), }), requestArgs: map[string]any{ @@ -1522,8 +1523,8 @@ func Test_GetPullRequestCommits(t *testing.T) { name: "lockdown enabled - author has push access", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{ - Number: github.Ptr(42), - User: &github.User{Login: github.Ptr("writer")}, + Number: new(42), + User: &github.User{Login: new("writer")}, }), GetReposPullsCommitsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockCommits), }), @@ -1542,8 +1543,8 @@ func Test_GetPullRequestCommits(t *testing.T) { name: "lockdown enabled - trusted bot author lacks push access", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{ - Number: github.Ptr(42), - User: &github.User{Login: github.Ptr("github-actions[bot]")}, + Number: new(42), + User: &github.User{Login: new("github-actions[bot]")}, }), GetReposPullsCommitsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockCommits), }), @@ -1652,54 +1653,54 @@ func Test_GetPullRequestStatus(t *testing.T) { // 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"), + Number: new(42), + Title: new("Test PR"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), Head: &github.PullRequestBranch{ - SHA: github.Ptr("abcd1234"), - Ref: github.Ptr("feature-branch"), + SHA: new("abcd1234"), + Ref: new("feature-branch"), }, } statusCreatedAt := &github.Timestamp{Time: time.Date(2026, time.August, 11, 9, 30, 0, 0, time.UTC)} statusUpdatedAt := &github.Timestamp{Time: time.Date(2026, time.August, 11, 9, 35, 0, 0, time.UTC)} mockStatus := &github.CombinedStatus{ - Name: github.Ptr("abcd1234"), - State: github.Ptr("success"), - SHA: github.Ptr("abcd1234"), - TotalCount: github.Ptr(2), - CommitURL: github.Ptr("https://api.github.com/repos/owner/repo/commits/abcd1234"), - RepositoryURL: github.Ptr( + Name: new("abcd1234"), + State: new("success"), + SHA: new("abcd1234"), + TotalCount: new(2), + CommitURL: new("https://api.github.com/repos/owner/repo/commits/abcd1234"), + RepositoryURL: new( "https://api.github.com/repos/owner/repo", ), Statuses: []*github.RepoStatus{ { - ID: github.Ptr(int64(101)), - NodeID: github.Ptr("SC_kwDOStatus101"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/statuses/abcd1234"), - State: github.Ptr("success"), - Context: github.Ptr("continuous-integration/travis-ci"), - Description: github.Ptr("Build succeeded"), - TargetURL: github.Ptr("https://travis-ci.org/owner/repo/builds/123"), - AvatarURL: github.Ptr("https://avatars.githubusercontent.com/in/123"), + ID: new(int64(101)), + NodeID: new("SC_kwDOStatus101"), + URL: new("https://api.github.com/repos/owner/repo/statuses/abcd1234"), + State: new("success"), + Context: new("continuous-integration/travis-ci"), + Description: new("Build succeeded"), + TargetURL: new("https://travis-ci.org/owner/repo/builds/123"), + AvatarURL: new("https://avatars.githubusercontent.com/in/123"), Creator: &github.User{ - Login: github.Ptr("ci-bot"), + Login: new("ci-bot"), }, CreatedAt: statusCreatedAt, UpdatedAt: statusUpdatedAt, }, { - State: github.Ptr("success"), - Context: github.Ptr("codecov/patch"), - Description: github.Ptr("Coverage increased"), - TargetURL: github.Ptr("https://codecov.io/gh/owner/repo/pull/42"), + State: new("success"), + Context: new("codecov/patch"), + Description: new("Coverage increased"), + TargetURL: new("https://codecov.io/gh/owner/repo/pull/42"), }, }, } emptyStatus := &github.CombinedStatus{ - State: github.Ptr("pending"), - SHA: github.Ptr("abcd1234"), - TotalCount: github.Ptr(0), + State: new("pending"), + SHA: new("abcd1234"), + TotalCount: new(0), Statuses: []*github.RepoStatus{nil}, } @@ -1880,32 +1881,32 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { // 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"), + Number: new(42), + Title: new("Test PR"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), Head: &github.PullRequestBranch{ - SHA: github.Ptr("abcd1234"), - Ref: github.Ptr("feature-branch"), + SHA: new("abcd1234"), + Ref: new("feature-branch"), }, } // Setup mock check runs for success case mockCheckRuns := &github.ListCheckRunsResults{ - Total: github.Ptr(2), + Total: new(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: new(int64(1)), + Name: new("build"), + Status: new("completed"), + Conclusion: new("success"), + HTMLURL: new("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"), + ID: new(int64(2)), + Name: new("test"), + Status: new("completed"), + Conclusion: new("success"), + HTMLURL: new("https://github.com/owner/repo/runs/2"), }, }, } @@ -2035,8 +2036,8 @@ func Test_UpdatePullRequestBranch(t *testing.T) { // Setup mock update result for success case mockUpdateResult := &github.PullRequestBranchUpdateResponse{ - Message: github.Ptr("Branch was updated successfully"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/pulls/42"), + Message: new("Branch was updated successfully"), + URL: new("https://api.github.com/repos/owner/repo/pulls/42"), } tests := []struct { @@ -2187,13 +2188,16 @@ func Test_GetPullRequestComments(t *testing.T) { "isOutdated": false, "isCollapsed": false, "comments": map[string]any{ - "totalCount": 2, + "totalCount": 3, "nodes": []map[string]any{ { - "id": "PRRC_kwDOA0xdyM4AX1Y0", - "body": "This looks good", - "path": "file1.go", - "line": 5, + "id": "PRRC_kwDOA0xdyM4AX1Y0", + "body": "This looks good", + "path": "file1.go", + "line": 86, + "originalLine": 84, + "startLine": 73, + "originalStartLine": 73, "author": map[string]any{ "login": "reviewer1", }, @@ -2202,10 +2206,11 @@ func Test_GetPullRequestComments(t *testing.T) { "url": "https://github.com/owner/repo/pull/42#discussion_r101", }, { - "id": "PRRC_kwDOA0xdyM4AX1Y1", - "body": "Please fix this", - "path": "file1.go", - "line": 10, + "id": "PRRC_kwDOA0xdyM4AX1Y1", + "body": "Please fix this", + "path": "file1.go", + "line": 159, + "originalLine": 157, "author": map[string]any{ "login": "reviewer2", }, @@ -2213,6 +2218,21 @@ func Test_GetPullRequestComments(t *testing.T) { "updatedAt": "2024-01-01T13:00:00Z", "url": "https://github.com/owner/repo/pull/42#discussion_r102", }, + { + "id": "PRRC_kwDOA0xdyM4AX1Y2", + "body": "Comment with current coordinates unavailable", + "path": "file1.go", + "line": nil, + "originalLine": 178, + "startLine": nil, + "originalStartLine": 176, + "author": map[string]any{ + "login": "reviewer3", + }, + "createdAt": "2024-01-01T14:00:00Z", + "updatedAt": "2024-01-01T14:00:00Z", + "url": "https://github.com/owner/repo/pull/42#discussion_r103", + }, }, }, }, @@ -2251,13 +2271,56 @@ func Test_GetPullRequestComments(t *testing.T) { assert.Equal(t, false, thread.IsCollapsed) // Validate comments within thread - assert.Len(t, thread.Comments, 2) + assert.Len(t, thread.Comments, 3) // Validate first comment 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) + require.NotNil(t, comment1.Line) + assert.Equal(t, 86, *comment1.Line) + require.NotNil(t, comment1.OriginalLine) + assert.Equal(t, 84, *comment1.OriginalLine) + require.NotNil(t, comment1.StartLine) + assert.Equal(t, 73, *comment1.StartLine) + require.NotNil(t, comment1.OriginalStartLine) + assert.Equal(t, 73, *comment1.OriginalStartLine) + + comment2 := thread.Comments[1] + require.NotNil(t, comment2.Line) + assert.Equal(t, 159, *comment2.Line) + require.NotNil(t, comment2.OriginalLine) + assert.Equal(t, 157, *comment2.OriginalLine) + assert.Nil(t, comment2.StartLine) + assert.Nil(t, comment2.OriginalStartLine) + + var raw struct { + ReviewThreads []struct { + Comments []map[string]any `json:"comments"` + } `json:"review_threads"` + } + require.NoError(t, json.Unmarshal([]byte(textContent), &raw)) + require.Len(t, raw.ReviewThreads, 1) + require.Len(t, raw.ReviewThreads[0].Comments, 3) + assert.Equal(t, float64(86), raw.ReviewThreads[0].Comments[0]["line"]) + assert.Equal(t, float64(84), raw.ReviewThreads[0].Comments[0]["original_line"]) + assert.Equal(t, float64(73), raw.ReviewThreads[0].Comments[0]["start_line"]) + assert.Equal(t, float64(73), raw.ReviewThreads[0].Comments[0]["original_start_line"]) + assert.NotContains(t, raw.ReviewThreads[0].Comments[1], "start_line") + assert.NotContains(t, raw.ReviewThreads[0].Comments[1], "original_start_line") + assert.NotContains(t, raw.ReviewThreads[0].Comments[2], "line") + assert.NotContains(t, raw.ReviewThreads[0].Comments[2], "start_line") + assert.Equal(t, float64(178), raw.ReviewThreads[0].Comments[2]["original_line"]) + assert.Equal(t, float64(176), raw.ReviewThreads[0].Comments[2]["original_start_line"]) + + comment3 := thread.Comments[2] + assert.Nil(t, comment3.Line) + require.NotNil(t, comment3.OriginalLine) + assert.Equal(t, 178, *comment3.OriginalLine) + assert.Nil(t, comment3.StartLine) + require.NotNil(t, comment3.OriginalStartLine) + assert.Equal(t, 176, *comment3.OriginalStartLine) // Validate pagination info assert.Equal(t, false, result.PageInfo.HasNextPage) @@ -2516,25 +2579,25 @@ func Test_GetPullRequestReviews(t *testing.T) { // Setup mock PR reviews for success case mockReviews := []*github.PullRequestReview{ { - ID: github.Ptr(int64(201)), - State: github.Ptr("APPROVED"), - Body: github.Ptr("LGTM"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#pullrequestreview-201"), + ID: new(int64(201)), + State: new("APPROVED"), + Body: new("LGTM"), + HTMLURL: new("https://github.com/owner/repo/pull/42#pullrequestreview-201"), User: &github.User{ - Login: github.Ptr("approver"), + Login: new("approver"), }, - CommitID: github.Ptr("abcdef123456"), + CommitID: new("abcdef123456"), SubmittedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, }, { - ID: github.Ptr(int64(202)), - State: github.Ptr("CHANGES_REQUESTED"), - Body: github.Ptr("Please address the following issues"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#pullrequestreview-202"), + ID: new(int64(202)), + State: new("CHANGES_REQUESTED"), + Body: new("Please address the following issues"), + HTMLURL: new("https://github.com/owner/repo/pull/42#pullrequestreview-202"), User: &github.User{ - Login: github.Ptr("reviewer"), + Login: new("reviewer"), }, - CommitID: github.Ptr("abcdef123456"), + CommitID: new("abcdef123456"), SubmittedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)}, }, } @@ -2611,16 +2674,16 @@ func Test_GetPullRequestReviews(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsReviewsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, []*github.PullRequestReview{ { - ID: github.Ptr(int64(2030)), - State: github.Ptr("APPROVED"), - Body: github.Ptr("Maintainer review"), - User: &github.User{Login: github.Ptr("maintainer")}, + ID: new(int64(2030)), + State: new("APPROVED"), + Body: new("Maintainer review"), + User: &github.User{Login: new("maintainer")}, }, { - ID: github.Ptr(int64(2031)), - State: github.Ptr("COMMENTED"), - Body: github.Ptr("External reviewer"), - User: &github.User{Login: github.Ptr("testuser")}, + ID: new(int64(2031)), + State: new("COMMENTED"), + Body: new("External reviewer"), + User: &github.User{Login: new("testuser")}, }, }), }), @@ -2633,10 +2696,10 @@ func Test_GetPullRequestReviews(t *testing.T) { expectError: false, expectedReviews: []*github.PullRequestReview{ { - ID: github.Ptr(int64(2030)), - State: github.Ptr("APPROVED"), - Body: github.Ptr("Maintainer review"), - User: &github.User{Login: github.Ptr("maintainer")}, + ID: new(int64(2030)), + State: new("APPROVED"), + Body: new("Maintainer review"), + User: &github.User{Login: new("maintainer")}, }, }, lockdownEnabled: true, @@ -2646,15 +2709,15 @@ func Test_GetPullRequestReviews(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsReviewsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, []*github.PullRequestReview{ { - ID: github.Ptr(int64(2040)), - State: github.Ptr("APPROVED"), - Body: github.Ptr("Ghost review"), - User: &github.User{Login: github.Ptr("")}, + ID: new(int64(2040)), + State: new("APPROVED"), + Body: new("Ghost review"), + User: &github.User{Login: new("")}, }, { - ID: github.Ptr(int64(2041)), - State: github.Ptr("COMMENTED"), - Body: github.Ptr("Another ghost review"), + ID: new(int64(2041)), + State: new("COMMENTED"), + Body: new("Another ghost review"), }, }), }), @@ -2751,23 +2814,23 @@ func Test_CreatePullRequest(t *testing.T) { // 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"), + Number: new(42), + Title: new("Test PR"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), Head: &github.PullRequestBranch{ - SHA: github.Ptr("abcd1234"), - Ref: github.Ptr("feature-branch"), + SHA: new("abcd1234"), + Ref: new("feature-branch"), }, Base: &github.PullRequestBranch{ - SHA: github.Ptr("efgh5678"), - Ref: github.Ptr("main"), + SHA: new("efgh5678"), + Ref: new("main"), }, - Body: github.Ptr("This is a test PR"), - Draft: github.Ptr(false), - MaintainerCanModify: github.Ptr(true), + Body: new("This is a test PR"), + Draft: new(false), + MaintainerCanModify: new(true), User: &github.User{ - Login: github.Ptr("testuser"), + Login: new("testuser"), }, } @@ -2886,12 +2949,12 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { t.Parallel() 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")}, + Number: new(42), + Title: new("Test PR"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{SHA: new("abc"), Ref: new("feature")}, + Base: &github.PullRequestBranch{SHA: new("def"), Ref: new("main")}, + User: &github.User{Login: new("testuser")}, } serverTool := CreatePullRequest(translations.NullTranslationHelper) @@ -2903,7 +2966,7 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { deps := BaseDeps{ Client: client, GQLClient: githubv4.NewClient(nil), - featureChecker: featureCheckerFor(MCPAppsFeatureFlag), + featureChecker: featureCheckerFor(), } handler := serverTool.Handler(deps) @@ -2987,12 +3050,12 @@ func Test_UpdatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { t.Parallel() mockPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Updated"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - Head: &github.PullRequestBranch{SHA: github.Ptr("abc"), Ref: github.Ptr("feature")}, - Base: &github.PullRequestBranch{SHA: github.Ptr("def"), Ref: github.Ptr("main")}, - User: &github.User{Login: github.Ptr("testuser")}, + Number: new(42), + Title: new("Updated"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{SHA: new("abc"), Ref: new("feature")}, + Base: &github.PullRequestBranch{SHA: new("def"), Ref: new("main")}, + User: &github.User{Login: new("testuser")}, } serverTool := UpdatePullRequest(translations.NullTranslationHelper) @@ -3005,7 +3068,7 @@ func Test_UpdatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { deps := BaseDeps{ Client: client, GQLClient: githubv4.NewClient(nil), - featureChecker: featureCheckerFor(MCPAppsFeatureFlag), + featureChecker: featureCheckerFor(), } handler := serverTool.Handler(deps) @@ -3604,7 +3667,7 @@ func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) { expectedToolErrMsg string }{ { - name: "successful line comment addition", + name: "selects viewer pending review by ID", requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -3618,18 +3681,21 @@ func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) { "startSide": "RIGHT", }, mockedClient: githubv4mock.NewMockedHTTPClient( - viewerQuery("williammartin"), - getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ - author: "williammartin", - owner: "owner", - repo: "repo", - prNum: 42, - - reviews: []getLatestPendingReviewQueryReview{ + viewerIDQuery("U_viewer"), + getPendingReviewsQuery(getPendingReviewsQueryParams{ + owner: "owner", + repo: "repo", + prNum: 42, + + reviews: []pendingReviewQueryReview{ + { + id: "PR_other", + authorID: "U_other", + }, { - id: "PR_kwDODKw3uc6WYN1T", - state: "PENDING", - url: "https://github.com/owner/repo/pull/42", + id: "PR_kwDODKw3uc6WYN1T", + authorID: "U_viewer", + authorField: "botId", }, }, }), @@ -3642,7 +3708,7 @@ func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) { } `graphql:"addPullRequestReviewThread(input: $input)"` }{}, githubv4.AddPullRequestReviewThreadInput{ - Path: githubv4.String("file.go"), + Path: githubv4.NewString("file.go"), Body: githubv4.String("This is a test comment"), SubjectType: githubv4mock.Ptr(githubv4.PullRequestReviewThreadSubjectTypeLine), Line: githubv4.NewInt(10), @@ -3677,18 +3743,16 @@ func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) { "startSide": "RIGHT", }, mockedClient: githubv4mock.NewMockedHTTPClient( - viewerQuery("williammartin"), - getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ - author: "williammartin", - owner: "owner", - repo: "repo", - prNum: 42, - - reviews: []getLatestPendingReviewQueryReview{ + viewerIDQuery("U_viewer"), + getPendingReviewsQuery(getPendingReviewsQueryParams{ + owner: "owner", + repo: "repo", + prNum: 42, + + reviews: []pendingReviewQueryReview{ { - id: "PR_kwDODKw3uc6WYN1T", - state: "PENDING", - url: "https://github.com/owner/repo/pull/42", + id: "PR_kwDODKw3uc6WYN1T", + authorID: "U_viewer", }, }, }), @@ -3701,7 +3765,7 @@ func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) { } `graphql:"addPullRequestReviewThread(input: $input)"` }{}, githubv4.AddPullRequestReviewThreadInput{ - Path: githubv4.String("file.go"), + Path: githubv4.NewString("file.go"), Body: githubv4.String("This is a test comment"), SubjectType: githubv4mock.Ptr(githubv4.PullRequestReviewThreadSubjectTypeLine), Line: githubv4.NewInt(10), @@ -3758,18 +3822,16 @@ func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) { "side": "RIGHT", }, mockedClient: githubv4mock.NewMockedHTTPClient( - viewerQuery("williammartin"), - getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ - author: "williammartin", - owner: "owner", - repo: "repo", - prNum: 42, - - reviews: []getLatestPendingReviewQueryReview{ + viewerIDQuery("U_viewer"), + getPendingReviewsQuery(getPendingReviewsQueryParams{ + owner: "owner", + repo: "repo", + prNum: 42, + + reviews: []pendingReviewQueryReview{ { - id: "PR_kwDODKw3uc6WYN1T", - state: "PENDING", - url: "https://github.com/owner/repo/pull/42", + id: "PR_kwDODKw3uc6WYN1T", + authorID: "U_viewer", }, }, }), @@ -3782,7 +3844,7 @@ func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) { } `graphql:"addPullRequestReviewThread(input: $input)"` }{}, githubv4.AddPullRequestReviewThreadInput{ - Path: githubv4.String("file.go"), + Path: githubv4.NewString("file.go"), Body: githubv4.String("Comment on non-existent line"), SubjectType: githubv4mock.Ptr(githubv4.PullRequestReviewThreadSubjectTypeLine), Line: githubv4.NewInt(999), @@ -3876,18 +3938,16 @@ func TestSubmitPendingPullRequestReview(t *testing.T) { "body": "This is a test review", }, mockedClient: githubv4mock.NewMockedHTTPClient( - viewerQuery("williammartin"), - getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ - author: "williammartin", - owner: "owner", - repo: "repo", - prNum: 42, - - reviews: []getLatestPendingReviewQueryReview{ + viewerIDQuery("U_viewer"), + getPendingReviewsQuery(getPendingReviewsQueryParams{ + owner: "owner", + repo: "repo", + prNum: 42, + + reviews: []pendingReviewQueryReview{ { - id: "PR_kwDODKw3uc6WYN1T", - state: "PENDING", - url: "https://github.com/owner/repo/pull/42", + id: "PR_kwDODKw3uc6WYN1T", + authorID: "U_viewer", }, }, }), @@ -3977,18 +4037,16 @@ func TestDeletePendingPullRequestReview(t *testing.T) { "pullNumber": float64(42), }, mockedClient: githubv4mock.NewMockedHTTPClient( - viewerQuery("williammartin"), - getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ - author: "williammartin", - owner: "owner", - repo: "repo", - prNum: 42, - - reviews: []getLatestPendingReviewQueryReview{ + viewerIDQuery("U_viewer"), + getPendingReviewsQuery(getPendingReviewsQueryParams{ + owner: "owner", + repo: "repo", + prNum: 42, + + reviews: []pendingReviewQueryReview{ { - id: "PR_kwDODKw3uc6WYN1T", - state: "PENDING", - url: "https://github.com/owner/repo/pull/42", + id: "PR_kwDODKw3uc6WYN1T", + authorID: "U_viewer", }, }, }), @@ -4043,6 +4101,57 @@ func TestDeletePendingPullRequestReview(t *testing.T) { } } +func TestGetPendingPullRequestReviewForViewerPaginates(t *testing.T) { + t.Parallel() + + var reviewQueries atomic.Int32 + transport := NewMockRoundTripper().OnRequest(http.MethodPost, "/graphql", func(w http.ResponseWriter, r *http.Request) { + var request struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(request.Query, "viewer"): + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "viewer": map[string]any{"id": "U_viewer"}, + }, + })) + case strings.Contains(request.Query, "reviews"): + queryNumber := reviewQueries.Add(1) + if queryNumber == 1 { + assert.Nil(t, request.Variables["after"]) + require.NoError(t, json.NewEncoder(w).Encode(pendingReviewsResponse( + []pendingReviewQueryReview{{id: "PR_other", authorID: "U_other"}}, + true, + "cursor-1", + ))) + return + } + + assert.Equal(t, "cursor-1", request.Variables["after"]) + require.NoError(t, json.NewEncoder(w).Encode(pendingReviewsResponse( + []pendingReviewQueryReview{{id: "PR_viewer", authorID: "U_viewer"}}, + false, + "", + ))) + default: + t.Fatalf("unexpected GraphQL query: %s", request.Query) + } + }) + + client := githubv4.NewClient(&http.Client{Transport: transport}) + reviewID, result := getPendingPullRequestReviewForViewer(context.Background(), client, "owner", "repo", 42) + + require.Nil(t, result) + require.NotNil(t, reviewID) + assert.Equal(t, githubv4.ID("PR_viewer"), *reviewID) + assert.Equal(t, int32(2), reviewQueries.Load()) +} + func TestGetPullRequestDiff(t *testing.T) { t.Parallel() @@ -4077,8 +4186,8 @@ index 5d6e7b2..8a4f5c3 100644 // author, then the raw diff; branch on the Accept header to serve both. prOrDiffHandler := func(authorLogin string) http.HandlerFunc { mockPR := &github.PullRequest{ - Number: github.Ptr(42), - User: &github.User{Login: github.Ptr(authorLogin)}, + Number: new(42), + User: &github.User{Login: new(authorLogin)}, } return func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.Header.Get("Accept"), "diff") { @@ -4189,76 +4298,103 @@ index 5d6e7b2..8a4f5c3 100644 } } -func viewerQuery(login string) githubv4mock.Matcher { +func viewerIDQuery(id string) githubv4mock.Matcher { return githubv4mock.NewQueryMatcher( struct { Viewer struct { - Login githubv4.String + ID githubv4.ID } `graphql:"viewer"` }{}, map[string]any{}, githubv4mock.DataResponse(map[string]any{ "viewer": map[string]any{ - "login": login, + "id": id, }, }), ) } -type getLatestPendingReviewQueryReview struct { - id string - state string - url string +type pendingReviewQueryReview struct { + id string + authorID string + authorField string } -type getLatestPendingReviewQueryParams struct { - author string - owner string - repo string - prNum int32 +type getPendingReviewsQueryParams struct { + owner string + repo string + prNum int32 - reviews []getLatestPendingReviewQueryReview + reviews []pendingReviewQueryReview } -func getLatestPendingReviewQuery(p getLatestPendingReviewQueryParams) githubv4mock.Matcher { - return githubv4mock.NewQueryMatcher( +func getPendingReviewsQuery(p getPendingReviewsQueryParams) githubv4mock.Matcher { + matcher := githubv4mock.NewQueryMatcher( struct { Repository struct { PullRequest struct { Reviews struct { Nodes []struct { - ID githubv4.ID - State githubv4.PullRequestReviewState - URL githubv4.URI + ID githubv4.ID + Author pendingReviewAuthor + } + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String } - } `graphql:"reviews(first: 1, author: $author)"` + } `graphql:"reviews(first: 100, after: $after, states: $states)"` } `graphql:"pullRequest(number: $prNum)"` } `graphql:"repository(owner: $owner, name: $name)"` }{}, map[string]any{ - "author": githubv4.String(p.author), + "after": (*githubv4.String)(nil), "owner": githubv4.String(p.owner), "name": githubv4.String(p.repo), "prNum": githubv4.Int(p.prNum), + "states": []githubv4.PullRequestReviewState{githubv4.PullRequestReviewStatePending}, }, - githubv4mock.DataResponse( - map[string]any{ - "repository": map[string]any{ - "pullRequest": map[string]any{ - "reviews": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": p.reviews[0].id, - "state": p.reviews[0].state, - "url": p.reviews[0].url, - }, - }, - }, + githubv4mock.DataResponse(map[string]any{ + "repository": pendingReviewsData(p.reviews, false, "")["repository"], + }), + ) + matcher.Variables["states"] = []any{"PENDING"} + return matcher +} + +func pendingReviewsResponse(reviews []pendingReviewQueryReview, hasNextPage bool, endCursor string) map[string]any { + return map[string]any{ + "data": pendingReviewsData(reviews, hasNextPage, endCursor), + } +} + +func pendingReviewsData(reviews []pendingReviewQueryReview, hasNextPage bool, endCursor string) map[string]any { + nodes := make([]any, 0, len(reviews)) + for _, review := range reviews { + authorField := review.authorField + if authorField == "" { + authorField = "userId" + } + nodes = append(nodes, map[string]any{ + "id": review.id, + "author": map[string]any{ + authorField: review.authorID, + }, + }) + } + + return map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "reviews": map[string]any{ + "nodes": nodes, + "pageInfo": map[string]any{ + "hasNextPage": hasNextPage, + "endCursor": endCursor, }, }, }, - ), - ) + }, + } } func TestAddReplyToPullRequestComment(t *testing.T) { @@ -4282,19 +4418,19 @@ func TestAddReplyToPullRequestComment(t *testing.T) { // 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"), + ID: new(int64(456)), + Body: new("This is a reply to the comment"), + InReplyTo: new(int64(123)), + HTMLURL: new("https://github.com/owner/repo/pull/42#discussion_r456"), User: &github.User{ - Login: github.Ptr("responder"), + Login: new("responder"), }, CreatedAt: &github.Timestamp{Time: time.Now()}, UpdatedAt: &github.Timestamp{Time: time.Now()}, } mockReaction := &github.Reaction{ - ID: github.Ptr(int64(789)), - Content: github.Ptr("rocket"), + ID: new(int64(789)), + Content: new("rocket"), } replyCreatedAfterReactionFailure := &atomic.Bool{} @@ -4546,12 +4682,13 @@ 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 string + requestArgs map[string]any + mockedClient *http.Client + withResolutionReason bool + expectToolError bool + expectedToolErrMsg string + expectedResult string }{ { name: "successful resolve thread", @@ -4572,7 +4709,81 @@ func TestResolveReviewThread(t *testing.T) { } } `graphql:"resolveReviewThread(input: $input)"` }{}, - githubv4.ResolveReviewThreadInput{ + 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 resolve thread with resolution reason", + withResolutionReason: true, + requestArgs: map[string]any{ + "method": "resolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "threadId": "PRRT_kwDOTest123", + "resolutionReason": "wont-fix", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + ResolveReviewThread struct { + Thread struct { + ID githubv4.ID + IsResolved githubv4.Boolean + } + } `graphql:"resolveReviewThread(input: $input)"` + }{}, + ResolveReviewThreadInput{ + ThreadID: githubv4.ID("PRRT_kwDOTest123"), + ResolutionReason: newGQLStringlike[githubv4.String]("wont-fix"), + }, + 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: "default variant omits resolution reason", + requestArgs: map[string]any{ + "method": "resolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "threadId": "PRRT_kwDOTest123", + "resolutionReason": "wont-fix", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + ResolveReviewThread struct { + Thread struct { + ID githubv4.ID + IsResolved githubv4.Boolean + } + } `graphql:"resolveReviewThread(input: $input)"` + }{}, + ResolveReviewThreadInput{ ThreadID: githubv4.ID("PRRT_kwDOTest123"), }, nil, @@ -4692,7 +4903,7 @@ func TestResolveReviewThread(t *testing.T) { } } `graphql:"resolveReviewThread(input: $input)"` }{}, - githubv4.ResolveReviewThreadInput{ + ResolveReviewThreadInput{ ThreadID: githubv4.ID("PRRT_invalid"), }, nil, @@ -4711,6 +4922,9 @@ func TestResolveReviewThread(t *testing.T) { // Setup client with mock client := githubv4.NewClient(tc.mockedClient) serverTool := PullRequestReviewWrite(translations.NullTranslationHelper) + if tc.withResolutionReason { + serverTool = PullRequestReviewWriteWithResolutionReason(translations.NullTranslationHelper) + } deps := BaseDeps{ GQLClient: client, } diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 8575d994cc..5db5880917 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -12,22 +12,55 @@ import ( "strings" "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/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/google/go-github/v89/github" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/octicons" + "github.com/github/github-mcp-server/v2/pkg/sanitize" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) +type GetCommitInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + SHA string `json:"sha"` + Detail string `json:"detail,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + schema := WithPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "sha": { + Type: "string", + Description: "Commit SHA, branch name, or tag name", + }, + "detail": { + Type: "string", + Enum: []any{"none", "stats", "full_patch"}, + Description: "Level of detail to include for changed files. \"none\" omits stats and files entirely. \"stats\" (default) includes per-file metadata: filename, status, and lines-of-code counts (additions, deletions, changes), with no patch content. \"full_patch\" additionally includes the unified diff content for each file and can be very large.", + Default: json.RawMessage(`"stats"`), + }, + }, + Required: []string{"owner", "repo", "sha"}, + }) + return NewToolWithSchemaOptions[GetCommitInput, MinimalCommit]( ToolsetMetadataRepos, mcp.Tool{ Name: "get_commit", @@ -36,56 +69,30 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_GET_COMMITS_USER_TITLE", "Get commit details"), ReadOnlyHint: true, }, - InputSchema: WithPagination(&jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "Repository owner", - }, - "repo": { - Type: "string", - Description: "Repository name", - }, - "sha": { - Type: "string", - Description: "Commit SHA, branch name, or tag name", - }, - "detail": { - Type: "string", - Enum: []any{"none", "stats", "full_patch"}, - Description: "Level of detail to include for changed files. \"none\" omits stats and files entirely. \"stats\" (default) includes per-file metadata: filename, status, and lines-of-code counts (additions, deletions, changes), with no patch content. \"full_patch\" additionally includes the unified diff content for each file and can be very large.", - Default: json.RawMessage(`"stats"`), - }, - }, - Required: []string{"owner", "repo", "sha"}, - }), + InputSchema: schema, }, scopes.PublicRead(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 + inventory.TypedSchemaOptions{ValidationInputSchema: relaxedPaginationValidationSchema(schema)}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input GetCommitInput) (*mcp.CallToolResult, MinimalCommit, error) { + if input.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), MinimalCommit{}, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), MinimalCommit{}, nil } - sha, err := RequiredParam[string](args, "sha") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.SHA == "" { + return utils.NewToolResultError("missing required parameter: sha"), MinimalCommit{}, nil } - detailRaw, err := OptionalParam[string](args, "detail") + detail, err := parseCommitDetail(input.Detail) if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), MinimalCommit{}, nil } - detail, err := parseCommitDetail(detailRaw) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + pagination := PaginationParams{Page: 1, PerPage: 30} + if input.Page != nil { + pagination.Page = *input.Page } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.PerPage != nil { + pagination.PerPage = *input.PerPage } opts := &github.ListOptions{ @@ -95,24 +102,24 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { client, err := deps.GetClient(ctx) if err != nil { - return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + return nil, MinimalCommit{}, fmt.Errorf("failed to get GitHub client: %w", err) } - commit, resp, err := client.Repositories.GetCommit(ctx, owner, repo, sha, opts) + commit, resp, err := client.Repositories.GetCommit(ctx, input.Owner, input.Repo, input.SHA, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to get commit: %s", sha), + fmt.Sprintf("failed to get commit: %s", input.SHA), resp, err, - ), nil, nil + ), MinimalCommit{}, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) + return nil, MinimalCommit{}, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get commit", resp, body), nil, nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get commit", resp, body), MinimalCommit{}, nil } // Convert to minimal commit @@ -120,7 +127,7 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { r, err := json.Marshal(minimalCommit) if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, MinimalCommit{}, fmt.Errorf("failed to marshal response: %w", err) } result := utils.NewToolResultText(string(r)) @@ -128,12 +135,61 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { // repos anyone can land it via a PR (untrusted), in private repos // only collaborators can (trusted). Confidentiality follows repo // visibility. - result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents) - return result, nil, nil + result = attachRepoVisibilityIFCLabel(ctx, deps, client, input.Owner, input.Repo, result, ifc.LabelCommitContents) + return result, minimalCommit, nil }, + normalizeTypedReadArguments(nil, false), ) } +type ListCommitsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + SHA string `json:"sha,omitempty"` + Author string `json:"author,omitempty"` + Path string `json:"path,omitempty"` + Since string `json:"since,omitempty"` + Until string `json:"until,omitempty"` + Fields []string `json:"fields,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type ListCommitOutput struct { + SHA *string `json:"sha,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + Commit *MinimalCommitInfo `json:"commit,omitempty"` + Author *MinimalUser `json:"author,omitempty"` + Committer *MinimalUser `json:"committer,omitempty"` +} + +func structuredListCommitsOutput(commits []MinimalCommit, fields []string) []ListCommitOutput { + output := make([]ListCommitOutput, 0, len(commits)) + selected := func(field string) bool { + return len(fields) == 0 || slices.Contains(fields, field) + } + for _, commit := range commits { + item := ListCommitOutput{} + if selected("sha") { + item.SHA = new(commit.SHA) + } + if selected("html_url") { + item.HTMLURL = new(commit.HTMLURL) + } + if selected("commit") { + item.Commit = commit.Commit + } + if selected("author") { + item.Author = commit.Author + } + if selected("committer") { + item.Committer = commit.Committer + } + output = append(output, item) + } + return output +} + // ListCommits creates a tool to get the list of commits of a branch in a GitHub // repository. func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { @@ -177,7 +233,7 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { ) WithPagination(schema) - return NewTool( + return NewToolWithSchemaOptions[ListCommitsInput, []ListCommitOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: "list_commits", @@ -189,42 +245,20 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { InputSchema: schema, }, scopes.PublicRead(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 + inventory.TypedSchemaOptions{ValidationInputSchema: relaxedPaginationValidationSchema(schema)}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListCommitsInput) (*mcp.CallToolResult, []ListCommitOutput, error) { + if input.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - sha, err := OptionalParam[string](args, "sha") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + pagination := PaginationParams{Page: 1, PerPage: 30} + if input.Page != nil { + pagination.Page = *input.Page } - author, err := OptionalParam[string](args, "author") - 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 - } - fields, err := OptionalStringArrayParam(args, "fields") - 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 + if input.PerPage != nil { + pagination.PerPage = *input.PerPage } // Set default perPage to 30 if not provided perPage := pagination.PerPage @@ -232,23 +266,23 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { perPage = 30 } opts := &github.CommitsListOptions{ - SHA: sha, - Path: path, - Author: author, + SHA: input.SHA, + Path: input.Path, + Author: input.Author, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: perPage, }, } - if sinceStr != "" { - sinceTime, err := parseISOTimestamp(sinceStr) + if input.Since != "" { + sinceTime, err := parseISOTimestamp(input.Since) 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 input.Until != "" { + untilTime, err := parseISOTimestamp(input.Until) if err != nil { return utils.NewToolResultError(fmt.Sprintf("invalid until timestamp: %s", err)), nil, nil } @@ -259,10 +293,10 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - commits, resp, err := client.Repositories.ListCommits(ctx, owner, repo, opts) + commits, resp, err := client.Repositories.ListCommits(ctx, input.Owner, input.Repo, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to list commits: %s", sha), + fmt.Sprintf("failed to list commits: %s", input.SHA), resp, err, ), nil, nil @@ -285,8 +319,8 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { filtered := false var payload any = minimalCommits - if len(fields) > 0 { - filteredCommits, err := filterEachField(minimalCommits, fields) + if len(input.Fields) > 0 { + filteredCommits, err := filterEachField(minimalCommits, input.Fields) if err != nil { return utils.NewToolResultErrorFromErr("failed to filter commits", err), nil, nil } @@ -305,15 +339,24 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { // Commit content is reachable from the repo's history; integrity // follows the same public-untrusted / private-trusted rule as file // contents. Confidentiality follows repo visibility. - result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents) - return result, nil, nil + result = attachRepoVisibilityIFCLabel(ctx, deps, client, input.Owner, input.Repo, result, ifc.LabelCommitContents) + return result, structuredListCommitsOutput(minimalCommits, input.Fields), nil }, + normalizeTypedReadArguments(nil, false), + normalizeRepositoryFieldsArguments, ) } // ListBranches creates a tool to list branches in a GitHub repository. +type RepositoryListInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[RepositoryListInput, []MinimalBranch]( ToolsetMetadataRepos, mcp.Tool{ Name: "list_branches", @@ -338,18 +381,19 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { }), }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input RepositoryListInput) (*mcp.CallToolResult, []MinimalBranch, error) { + if input.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + pagination := PaginationParams{Page: 1, PerPage: 30} + if input.Page != nil { + pagination.Page = *input.Page + } + if input.PerPage != nil { + pagination.PerPage = *input.PerPage } opts := &github.BranchListOptions{ @@ -364,7 +408,7 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts) + branches, resp, err := client.Repositories.ListBranches(ctx, input.Owner, input.Repo, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list branches", @@ -397,23 +441,23 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { // Branches are structural repo metadata that only collaborators // with push access can create, so integrity is trusted. // Confidentiality follows repo visibility. - result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) - return result, nil, nil + result = attachRepoVisibilityIFCLabel(ctx, deps, client, input.Owner, input.Repo, result, ifc.LabelRepoMetadata) + return result, minimalBranches, nil }, + normalizeTypedReadArguments(nil, false), ) } // CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository. func CreateOrUpdateFile(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewTool[CreateOrUpdateFileInput, *MinimalFileContentResponse]( ToolsetMetadataRepos, mcp.Tool{ Name: "create_or_update_file", Description: t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", `Create or update a single file in a GitHub repository. 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 rev-parse : +To obtain the current blob SHA before updating, call the get_file_contents tool with the same owner, repo, and path, and set its ref parameter to this tool's branch value. The first text result reports the blob SHA for the requested path. SHA MUST be provided for existing file updates. `), @@ -450,7 +494,7 @@ SHA MUST be provided for existing file updates. }, "sha": { Type: "string", - Description: "The blob SHA of the file being replaced. Required if the file already exists.", + Description: "The blob SHA of the file being replaced. Required if the file already exists. Retrieve it with get_file_contents using the same owner, repo, and path, with ref set to this tool's branch value.", }, "allow_symlink_write": { Type: "boolean", @@ -462,34 +506,34 @@ SHA MUST be provided for existing file updates. }, }, scopes.RequireAll(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input CreateOrUpdateFileInput) (*mcp.CallToolResult, *MinimalFileContentResponse, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - path, err := RequiredParam[string](args, "path") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + path := input.Path + if path == "" { + return utils.NewToolResultError("missing required parameter: path"), nil, nil } - path, err = validateRelativePath(path) + path, err := validateRelativePath(path) if err != nil { return utils.NewToolResultError(fmt.Sprintf("invalid path: %s", err)), nil, nil } - content, err := RequiredParam[string](args, "content") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + content := input.Content + if content == "" { + return utils.NewToolResultError("missing required parameter: content"), nil, nil } - message, err := RequiredParam[string](args, "message") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + message := input.Message + if message == "" { + return utils.NewToolResultError("missing required parameter: message"), nil, nil } - branch, err := RequiredParam[string](args, "branch") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + branch := input.Branch + if branch == "" { + return utils.NewToolResultError("missing required parameter: branch"), nil, nil } // json.Marshal encodes byte arrays with base64, which is required for the API. @@ -497,24 +541,18 @@ SHA MUST be provided for existing file updates. // Create the file options opts := &github.RepositoryContentFileOptions{ - Message: github.Ptr(message), + Message: new(message), Content: contentBytes, - Branch: github.Ptr(branch), + Branch: new(branch), } // If SHA is provided, set it (for updates) - sha, err := OptionalParam[string](args, "sha") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + sha := input.SHA if sha != "" { - opts.SHA = github.Ptr(sha) + opts.SHA = new(sha) } - allowSymlinkWrite, err := OptionalParam[bool](args, "allow_symlink_write") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + allowSymlinkWrite := input.AllowSymlinkWrite // Create or update the file client, err := deps.GetClient(ctx) @@ -551,8 +589,10 @@ SHA MUST be provided for existing file updates. 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 + "The file changed since you read it. Call get_file_contents with owner=%q, repo=%q, path=%q, and ref=%q; "+ + "its first text result reports the blob SHA for the requested path. "+ + "Rebuild your content against what it returns, and retry with the sha parameter set to the SHA that call reports.", + sha, currentSHA, owner, repo, path, branch)), nil, nil } if !allowSymlinkWrite { if existingFile.GetType() == "symlink" { @@ -596,8 +636,10 @@ SHA MUST be provided for existing file updates. // 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 + "Call get_file_contents with owner=%q, repo=%q, path=%q, and ref=%q to read the file you are about to overwrite; "+ + "its first text result reports the blob SHA for the requested path. "+ + "Then retry with the sha parameter set to the blob SHA that call reports.", + path, owner, repo, path, branch)), nil, nil } // If file not found, no previous SHA needed (new file creation) } @@ -622,7 +664,7 @@ SHA MUST be provided for existing file updates. minimalResponse := convertToMinimalFileContentResponse(fileContent) - return MarshalledTextResult(minimalResponse), nil, nil + return MarshalledTextResult(minimalResponse), &minimalResponse, nil }, ) tool.ScopeAccess = scopes.DynamicChallenge( @@ -635,7 +677,7 @@ SHA MUST be provided for existing file updates. // CreateRepository creates a tool to create a new GitHub repository. func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[CreateRepositoryInput, *MinimalResponse]( ToolsetMetadataRepos, mcp.Tool{ Name: "create_repository", @@ -673,33 +715,24 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - name, err := RequiredParam[string](args, "name") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - description, err := OptionalParam[string](args, "description") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - organization, err := OptionalParam[string](args, "organization") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - private, err := OptionalBoolParamWithDefault(args, "private", true) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input CreateRepositoryInput) (*mcp.CallToolResult, *MinimalResponse, error) { + name := input.Name + if name == "" { + return utils.NewToolResultError("missing required parameter: name"), nil, nil } - autoInit, err := OptionalParam[bool](args, "autoInit") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + description := input.Description + organization := input.Organization + private := true + if input.Private != nil { + private = *input.Private } + autoInit := input.AutoInit repo := &github.Repository{ - Name: github.Ptr(name), - Description: github.Ptr(description), - Private: github.Ptr(private), - AutoInit: github.Ptr(autoInit), + Name: new(name), + Description: new(description), + Private: new(private), + AutoInit: new(autoInit), } client, err := deps.GetClient(ctx) @@ -735,7 +768,7 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &minimalResponse, nil }, ) } @@ -757,7 +790,7 @@ type deleteRepositoryState struct { // DeleteRepository creates a tool that deletes a GitHub repository after the // user confirms its full name through elicitation. func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewTool[RepositoryInput, *RepositoryMessageOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: DeleteRepositoryToolName, @@ -765,7 +798,7 @@ func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_DELETE_REPOSITORY_USER_TITLE", "Delete repository"), ReadOnlyHint: false, - DestructiveHint: github.Ptr(true), + DestructiveHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -783,14 +816,14 @@ func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool }, }, scopes.RequireAll(scopes.DeleteRepo, scopes.Repo), - 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 + func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, input RepositoryInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } fullName := owner + "/" + repo @@ -909,7 +942,8 @@ func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to delete repository", resp, body), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Repository %s was deleted.", fullName)), nil, nil + message := fmt.Sprintf("Repository %s was deleted.", fullName) + return utils.NewToolResultText(message), &RepositoryMessageOutput{Message: message}, nil }, ) tool.MinimumProtocolVersion = inventory.ProtocolVersionMultiRoundTrip @@ -981,11 +1015,12 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool fileContentFieldEnum, ) - return NewTool( + return NewTool[GetFileContentsInput, *RepositoryContentsOutput]( ToolsetMetadataRepos, mcp.Tool{ - Name: "get_file_contents", - Description: t("TOOL_GET_FILE_CONTENTS_DESCRIPTION", "Get the contents of a file or directory from a GitHub repository"), + Name: "get_file_contents", + OutputSchema: repositoryContentsOutputSchema(), + Description: t("TOOL_GET_FILE_CONTENTS_DESCRIPTION", "Get the contents of a file or directory from a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_FILE_CONTENTS_USER_TITLE", "Get file or directory contents"), ReadOnlyHint: true, @@ -993,37 +1028,25 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool InputSchema: schema, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input GetFileContentsInput) (*mcp.CallToolResult, *RepositoryContentsOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - path, err := OptionalParam[string](args, "path") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + path := input.Path path = strings.TrimPrefix(path, "/") - ref, err := OptionalParam[string](args, "ref") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + ref := input.Ref originalRef := ref - sha, err := OptionalParam[string](args, "sha") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + sha := input.SHA - fields, err := OptionalStringArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + fields := input.Fields client, err := deps.GetClient(ctx) if err != nil { @@ -1060,8 +1083,11 @@ 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) { - res, data, err := matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0) - return attachIFC(res), data, err + res, _, err := matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0) + if err != nil { + return nil, nil, err + } + return repositoryContentResult(attachIFC(res)) } if fileContent != nil && fileContent.SHA != nil { @@ -1089,10 +1115,10 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultError(fmt.Sprintf("failed to inspect repository file: %s", err)), nil, nil } if read.Metadata != nil && read.Metadata.Type == "submodule" { - return attachIFC(utils.NewToolResultText(marshalRepositoryPathMetadata(read.Metadata, "", successNote))), nil, nil + return repositoryContentResult(attachIFC(utils.NewToolResultText(marshalRepositoryPathMetadata(read.Metadata, "", successNote)))) } if read.Metadata != nil && fileContent.GetType() == "symlink" && !read.ContentAvailable { - return attachIFC(utils.NewToolResultText(marshalRepositoryPathMetadata(read.Metadata, "not_returned", successNote))), nil, nil + return repositoryContentResult(attachIFC(utils.NewToolResultText(marshalRepositoryPathMetadata(read.Metadata, "not_returned", successNote)))) } // Empty files (0 bytes) have no content to decode; return @@ -1108,7 +1134,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool } message := fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote) message = repositoryReadMessage(read, message, successNote) - return attachIFC(utils.NewToolResultResource(message, result)), nil, nil + return repositoryContentResult(attachIFC(utils.NewToolResultResource(message, result))) } // For files >= 1MB, return a ResourceLink instead of content @@ -1126,9 +1152,9 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool resourceLink.Title = fmt.Sprintf("Dereferenced target %s via symlink %s", read.Metadata.ResolvedTargetPath, path) } message = repositoryReadMessage(read, message, successNote) - return attachIFC(utils.NewToolResultResourceLink( + return repositoryContentResult(attachIFC(utils.NewToolResultResourceLink( message, - resourceLink)), nil, nil + resourceLink))) } if !read.ContentAvailable { @@ -1156,7 +1182,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool } message := fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote) message = repositoryReadMessage(read, message, successNote) - return attachIFC(utils.NewToolResultResource(message, result)), nil, nil + return repositoryContentResult(attachIFC(utils.NewToolResultResource(message, result))) } result := &mcp.ResourceContents{ @@ -1166,7 +1192,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool } message := fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote) message = repositoryReadMessage(read, message, successNote) - return attachIFC(utils.NewToolResultResource(message, result)), nil, nil + return repositoryContentResult(attachIFC(utils.NewToolResultResource(message, result))) } else if dirContent != nil { // file content or file SHA is nil which means it's a directory filtered := false @@ -1184,11 +1210,23 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultError("failed to marshal response"), nil, nil } recordDirContentsFieldsUsage(ctx, deps, dirContent, filtered, len(r)) - return attachIFC(utils.NewToolResultText(string(r))), nil, nil + var output []*RepositoryDirectoryEntryOutput + if err := json.Unmarshal(r, &output); err != nil { + return nil, nil, fmt.Errorf("failed to decode typed directory contents: %w", err) + } + if len(fields) == 0 { + for _, entry := range output { + if entry != nil { + entry.URL, entry.GitURL = nil, nil + } + } + } + return attachIFC(utils.NewToolResultText(string(r))), &RepositoryContentsOutput{Directory: output}, nil } return utils.NewToolResultError("failed to get file contents"), nil, nil }, + normalizeRepositoryFieldsArguments, ) } @@ -1200,12 +1238,13 @@ func recordDirContentsFieldsUsage(ctx context.Context, deps ToolDependencies, fu // ForkRepository creates a tool to fork a repository. func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ForkRepositoryInput, *ForkRepositoryOutput]( ToolsetMetadataRepos, mcp.Tool{ - Name: "fork_repository", - Description: t("TOOL_FORK_REPOSITORY_DESCRIPTION", "Fork a GitHub repository to your account or specified organization"), - Icons: octicons.Icons("repo-forked"), + Name: "fork_repository", + OutputSchema: forkRepositoryOutputSchema(), + Description: t("TOOL_FORK_REPOSITORY_DESCRIPTION", "Fork a GitHub repository to your account or specified organization"), + Icons: octicons.Icons("repo-forked"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_FORK_REPOSITORY_USER_TITLE", "Fork repository"), ReadOnlyHint: false, @@ -1229,20 +1268,17 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, - scopes.RequireAll(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 + publicRepositoryWriteScopeAccess(), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ForkRepositoryInput) (*mcp.CallToolResult, *ForkRepositoryOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - org, err := OptionalParam[string](args, "organization") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } + org := input.Organization opts := &github.RepositoryCreateForkOptions{} if org != "" { @@ -1258,7 +1294,7 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { // Check if it's an acceptedError. An acceptedError indicates that the update is in progress, // and it's not a real error. if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) { - return utils.NewToolResultText("Fork is in progress"), nil, nil + return utils.NewToolResultText("Fork is in progress"), &ForkRepositoryOutput{Message: &RepositoryMessageOutput{Message: "Fork is in progress"}}, nil } return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to fork repository", @@ -1287,7 +1323,7 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ForkRepositoryOutput{Repository: &minimalResponse}, nil }, ) } @@ -1299,15 +1335,16 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { // The approach implemented here gets automatic commit signing when used with either the github-actions user or as an app, // both of which suit an LLM well. func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewTool[DeleteFileInput, *DeleteFileOutput]( ToolsetMetadataRepos, mcp.Tool{ - Name: "delete_file", - Description: t("TOOL_DELETE_FILE_DESCRIPTION", "Delete a file from a GitHub repository"), + Name: "delete_file", + OutputSchema: deleteFileOutputSchema(), + Description: t("TOOL_DELETE_FILE_DESCRIPTION", "Delete a file from a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_DELETE_FILE_USER_TITLE", "Delete file"), ReadOnlyHint: false, - DestructiveHint: github.Ptr(true), + DestructiveHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -1337,30 +1374,30 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, scopes.RequireAll(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input DeleteFileInput) (*mcp.CallToolResult, *DeleteFileOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - path, err := RequiredParam[string](args, "path") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + path := input.Path + if path == "" { + return utils.NewToolResultError("missing required parameter: path"), nil, nil } - path, err = validateRelativePath(path) + path, err := validateRelativePath(path) if err != nil { return utils.NewToolResultError(fmt.Sprintf("invalid path: %s", err)), nil, nil } - message, err := RequiredParam[string](args, "message") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + message := input.Message + if message == "" { + return utils.NewToolResultError("missing required parameter: message"), nil, nil } - branch, err := RequiredParam[string](args, "branch") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + branch := input.Branch + if branch == "" { + return utils.NewToolResultError("missing required parameter: branch"), nil, nil } client, err := deps.GetClient(ctx) @@ -1397,9 +1434,9 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { // Create a tree entry for the file deletion by setting SHA to nil treeEntries := []*github.TreeEntry{ { - Path: github.Ptr(path), - Mode: github.Ptr("100644"), // Regular file mode - Type: github.Ptr("blob"), + Path: new(path), + Mode: new("100644"), // Regular file mode + Type: new("blob"), SHA: nil, // Setting SHA to nil deletes the file }, } @@ -1425,7 +1462,7 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { // Create a new commit with the new tree commit := github.Commit{ - Message: github.Ptr(message), + Message: new(message), Tree: newTree, Parents: []*github.Commit{{SHA: baseCommit.SHA}}, } @@ -1451,7 +1488,7 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { ref.Object.SHA = newCommit.SHA _, resp, err = client.Git.UpdateRef(ctx, owner, repo, *ref.Ref, github.UpdateRef{ SHA: *newCommit.SHA, - Force: github.Ptr(false), + Force: new(false), }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, @@ -1471,17 +1508,17 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { } // Create a response similar to what the DeleteFile API would return - response := map[string]any{ - "commit": newCommit, - "content": nil, - } + response := struct { + Commit *github.Commit `json:"commit"` + Content *struct{} `json:"content"` + }{Commit: newCommit} 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 utils.NewToolResultText(string(r)), &DeleteFileOutput{Commit: repositoryGitCommitOutput(newCommit)}, nil }, ) tool.ScopeAccess = scopes.DynamicChallenge( @@ -1494,7 +1531,7 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { // CreateBranch creates a tool to create a new branch. func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[CreateBranchInput, *RepositoryReferenceOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: "create_branch", @@ -1526,24 +1563,21 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "branch"}, }, }, - scopes.RequireAll(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 + publicRepositoryWriteScopeAccess(), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input CreateBranchInput) (*mcp.CallToolResult, *RepositoryReferenceOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - branch, err := RequiredParam[string](args, "branch") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - fromBranch, err := OptionalParam[string](args, "from_branch") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + branch := input.Branch + if branch == "" { + return utils.NewToolResultError("missing required parameter: branch"), nil, nil } + fromBranch := input.FromBranch client, err := deps.GetClient(ctx) if err != nil { @@ -1600,14 +1634,14 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), repositoryReferenceOutput(createdRef), nil }, ) } // PushFiles creates a tool to push multiple files in a single commit to a GitHub repository. func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewTool[PushFilesInput, *RepositoryReferenceOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: "push_files", @@ -1658,57 +1692,47 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "branch", "files", "message"}, }, }, - scopes.RequireAll(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 + publicRepositoryWriteScopeAccess(), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input PushFilesInput) (*mcp.CallToolResult, *RepositoryReferenceOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - branch, err := RequiredParam[string](args, "branch") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + branch := input.Branch + if branch == "" { + return utils.NewToolResultError("missing required parameter: branch"), nil, nil } - message, err := RequiredParam[string](args, "message") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + message := input.Message + if message == "" { + return utils.NewToolResultError("missing required parameter: message"), nil, nil } - // Parse files parameter - this should be an array of objects with path and content - filesObj, ok := args["files"].([]any) - if !ok { + if input.Files == nil { return utils.NewToolResultError("files parameter must be an array of objects with path and content"), nil, nil } - - entries := make([]*github.TreeEntry, 0, len(filesObj)) - for _, file := range filesObj { - fileMap, ok := file.(map[string]any) - if !ok { - return utils.NewToolResultError("each file must be an object with path and content"), nil, nil - } - - filePath, ok := fileMap["path"].(string) - if !ok || filePath == "" { + entries := make([]*github.TreeEntry, 0, len(input.Files)) + for _, file := range input.Files { + if file.Path == "" { return utils.NewToolResultError("each file must have a path"), nil, nil } - filePath, err = validateRelativePath(filePath) + filePath, err := validateRelativePath(file.Path) if err != nil { return utils.NewToolResultError(fmt.Sprintf("invalid file path: %s", err)), nil, nil } - - content, ok := fileMap["content"].(string) - if !ok { + if file.Content == nil { return utils.NewToolResultError("each file must have content"), nil, nil } + content := *file.Content entries = append(entries, &github.TreeEntry{ - Path: github.Ptr(filePath), - Mode: github.Ptr("100644"), - Type: github.Ptr("blob"), - Content: github.Ptr(content), + Path: new(filePath), + Mode: new("100644"), + Type: new("blob"), + Content: new(content), }) } @@ -1800,7 +1824,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { // Create a new commit (baseCommit always has a value now) commit := github.Commit{ - Message: github.Ptr(message), + Message: new(message), Tree: newTree, Parents: []*github.Commit{{SHA: baseCommit.SHA}}, } @@ -1820,7 +1844,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { ref.Object.SHA = newCommit.SHA updatedRef, resp, err := client.Git.UpdateRef(ctx, owner, repo, *ref.Ref, github.UpdateRef{ SHA: *newCommit.SHA, - Force: github.Ptr(false), + Force: new(false), }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, @@ -1836,8 +1860,9 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), repositoryReferenceOutput(updatedRef), nil }, + normalizePushFilesArguments, ) tool.ScopeAccess = scopes.DynamicChallenge( []scopes.Scope{scopes.Repo, scopes.Workflow}, @@ -1849,7 +1874,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { // ListTags creates a tool to list tags in a GitHub repository. func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[RepositoryListInput, []MinimalTag]( ToolsetMetadataRepos, mcp.Tool{ Name: "list_tags", @@ -1874,18 +1899,19 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { }), }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input RepositoryListInput) (*mcp.CallToolResult, []MinimalTag, error) { + if input.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + pagination := PaginationParams{Page: 1, PerPage: 30} + if input.Page != nil { + pagination.Page = *input.Page + } + if input.PerPage != nil { + pagination.PerPage = *input.PerPage } opts := &github.ListOptions{ @@ -1898,7 +1924,7 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, opts) + tags, resp, err := client.Repositories.ListTags(ctx, input.Owner, input.Repo, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list tags", @@ -1932,19 +1958,21 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { // Tags are structural repo metadata created by collaborators with // push access, so integrity is trusted. Confidentiality follows // repo visibility. - result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) - return result, nil, nil + result = attachRepoVisibilityIFCLabel(ctx, deps, client, input.Owner, input.Repo, result, ifc.LabelRepoMetadata) + return result, minimalTags, nil }, + normalizeTypedReadArguments(nil, false), ) } // GetTag creates a tool to get details about a specific tag in a GitHub repository. func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[RepositoryTagInput, *RepositoryTagOutput]( ToolsetMetadataRepos, mcp.Tool{ - Name: "get_tag", - Description: t("TOOL_GET_TAG_DESCRIPTION", "Get details about a specific git tag in a GitHub repository"), + Name: "get_tag", + OutputSchema: repositoryTagOutputSchema(), + Description: t("TOOL_GET_TAG_DESCRIPTION", "Get details about a specific git tag in a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_TAG_USER_TITLE", "Get tag details"), ReadOnlyHint: true, @@ -1969,18 +1997,18 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input RepositoryTagInput) (*mcp.CallToolResult, *RepositoryTagOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - tag, err := RequiredParam[string](args, "tag") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + tag := input.Tag + if tag == "" { + return utils.NewToolResultError("missing required parameter: tag"), nil, nil } client, err := deps.GetClient(ctx) @@ -2015,7 +2043,7 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { } result := utils.NewToolResultText(string(r)) result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) - return result, nil, nil + return result, &RepositoryTagOutput{Reference: repositoryReferenceOutput(ref)}, nil } tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA) @@ -2046,7 +2074,7 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { // collaborator with push access. Confidentiality follows repo // visibility. result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) - return result, nil, nil + return result, &RepositoryTagOutput{Tag: repositoryAnnotatedTagOutput(tagObj)}, nil }, ) } @@ -2073,7 +2101,7 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { ) WithPagination(schema) - return NewTool( + return NewTool[ListReleasesInput, []ReleaseListOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: "list_releases", @@ -2085,23 +2113,17 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { InputSchema: schema, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListReleasesInput) (*mcp.CallToolResult, []ReleaseListOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - fields, err := OptionalStringArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } + fields := input.Fields + pagination := repositoryPagination(input.Page, input.PerPage) opts := &github.ListOptions{ Page: pagination.Page, @@ -2169,18 +2191,25 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { func(isPrivate bool) ifc.SecurityLabel { return ifc.LabelRelease(isPrivate, hasDraft) }) - return result, nil, nil + var output []ReleaseListOutput + if err := json.Unmarshal(r, &output); err != nil { + return nil, nil, fmt.Errorf("failed to decode typed releases: %w", err) + } + return result, output, nil }, + normalizeRepositoryFieldsArguments, + normalizeRepositoryArguments, ) } // GetLatestRelease creates a tool to get the latest release in a GitHub repository. func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[RepositoryInput, *MinimalRelease]( ToolsetMetadataRepos, mcp.Tool{ - Name: "get_latest_release", - Description: t("TOOL_GET_LATEST_RELEASE_DESCRIPTION", "Get the latest release in a GitHub repository"), + Name: "get_latest_release", + OutputSchema: repositoryReleaseOutputSchema(), + Description: t("TOOL_GET_LATEST_RELEASE_DESCRIPTION", "Get the latest release in a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_LATEST_RELEASE_USER_TITLE", "Get latest release"), ReadOnlyHint: true, @@ -2201,14 +2230,14 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool }, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input RepositoryInput) (*mcp.CallToolResult, *MinimalRelease, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } client, err := deps.GetClient(ctx) @@ -2230,6 +2259,7 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get latest release", resp, body), nil, nil } + sanitizeReleaseNameAndBody(release) r, err := json.Marshal(release) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) @@ -2244,17 +2274,19 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool func(isPrivate bool) ifc.SecurityLabel { return ifc.LabelRelease(isPrivate, release.GetDraft()) }) - return result, nil, nil + output := convertToMinimalRelease(release) + return result, &output, nil }, ) } func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[RepositoryTagInput, *MinimalRelease]( ToolsetMetadataRepos, mcp.Tool{ - Name: "get_release_by_tag", - Description: t("TOOL_GET_RELEASE_BY_TAG_DESCRIPTION", "Get a specific release by its tag name in a GitHub repository"), + Name: "get_release_by_tag", + OutputSchema: repositoryReleaseOutputSchema(), + Description: t("TOOL_GET_RELEASE_BY_TAG_DESCRIPTION", "Get a specific release by its tag name in a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_RELEASE_BY_TAG_USER_TITLE", "Get a release by tag name"), ReadOnlyHint: true, @@ -2279,18 +2311,18 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool }, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input RepositoryTagInput) (*mcp.CallToolResult, *MinimalRelease, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - tag, err := RequiredParam[string](args, "tag") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + tag := input.Tag + if tag == "" { + return utils.NewToolResultError("missing required parameter: tag"), nil, nil } client, err := deps.GetClient(ctx) @@ -2316,6 +2348,7 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get release by tag", resp, body), nil, nil } + sanitizeReleaseNameAndBody(release) r, err := json.Marshal(release) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) @@ -2330,14 +2363,27 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool func(isPrivate bool) ifc.SecurityLabel { return ifc.LabelRelease(isPrivate, release.GetDraft()) }) - return result, nil, nil + output := convertToMinimalRelease(release) + return result, &output, nil }, ) } +func sanitizeReleaseNameAndBody(release *github.RepositoryRelease) { + if release == nil { + return + } + if release.Name != nil { + release.Name = new(sanitize.PlainText(*release.Name)) + } + if release.Body != nil { + release.Body = new(sanitize.Content(*release.Body)) + } +} + // ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ListStarredRepositoriesInput, []MinimalRepository]( ToolsetMetadataStargazers, mcp.Tool{ Name: "list_starred_repositories", @@ -2367,23 +2413,11 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.Ser }), }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - username, err := OptionalParam[string](args, "username") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - sort, err := OptionalParam[string](args, "sort") - 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 - } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListStarredRepositoriesInput) (*mcp.CallToolResult, []MinimalRepository, error) { + username := input.Username + sort := input.Sort + direction := input.Direction + pagination := repositoryPagination(input.Page, input.PerPage) opts := &github.ActivityListStarredOptions{ ListOptions: github.ListOptions{ @@ -2474,14 +2508,15 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.Ser visibilities = append(visibilities, mr.Private) } result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelSearchIssues) - return result, nil, nil + return result, minimalRepos, nil }, + normalizeRepositoryArguments, ) } // StarRepository creates a tool to star a repository. func StarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[RepositoryInput, *RepositoryMessageOutput]( ToolsetMetadataStargazers, mcp.Tool{ Name: "star_repository", @@ -2507,14 +2542,14 @@ func StarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, scopes.RequireAll(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input RepositoryInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } client, err := deps.GetClient(ctx) @@ -2540,14 +2575,15 @@ func StarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to star repository", resp, body), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Successfully starred repository %s/%s", owner, repo)), nil, nil + message := fmt.Sprintf("Successfully starred repository %s/%s", owner, repo) + return utils.NewToolResultText(message), &RepositoryMessageOutput{Message: message}, nil }, ) } // UnstarRepository creates a tool to unstar a repository. func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[RepositoryInput, *RepositoryMessageOutput]( ToolsetMetadataStargazers, mcp.Tool{ Name: "unstar_repository", @@ -2572,14 +2608,14 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool }, }, scopes.RequireAll(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input RepositoryInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } client, err := deps.GetClient(ctx) @@ -2605,7 +2641,8 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to unstar repository", resp, body), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Successfully unstarred repository %s/%s", owner, repo)), nil, nil + message := fmt.Sprintf("Successfully unstarred repository %s/%s", owner, repo) + return utils.NewToolResultText(message), &RepositoryMessageOutput{Message: message}, nil }, ) } @@ -2654,7 +2691,7 @@ type BlameAuthor struct { type BlameCommit struct { SHA string `json:"sha"` MessageHeadline string `json:"message_headline"` - CommittedDate string `json:"committed_date"` + CommittedDate string `json:"committed_date" jsonschema:"Commit timestamp in RFC3339 format."` Author BlameAuthor `json:"author"` } @@ -2664,9 +2701,9 @@ type BlameCommit struct { // touching the file (0 = newest), not an absolute time delta. See: // https://docs.github.com/en/graphql/reference/objects#blamerange type BlameRange struct { - StartingLine int `json:"starting_line"` - EndingLine int `json:"ending_line"` - Age int `json:"age"` + StartingLine int `json:"starting_line" jsonschema:"First line of the inclusive, one-based range."` + EndingLine int `json:"ending_line" jsonschema:"Last line of the inclusive, one-based range."` + Age int `json:"age" jsonschema:"Relative commit age among commits touching this file; 0 is newest."` CommitSHA string `json:"commit_sha"` } @@ -2681,8 +2718,8 @@ type BlameResult struct { Ranges []BlameRange `json:"ranges"` Commits map[string]BlameCommit `json:"commits"` PageInfo MinimalPageInfo `json:"pageInfo"` - TotalRanges int `json:"total_ranges"` - Truncated bool `json:"truncated,omitempty"` + TotalRanges int `json:"total_ranges" jsonschema:"Matching ranges before pagination or truncation."` + Truncated bool `json:"truncated,omitempty" jsonschema:"Whether the 1000-range safety limit was reached."` } // blameCommitFragment is the GraphQL selection for a Commit's blame data. @@ -2730,10 +2767,11 @@ func validateBlamePath(p string) error { } func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[GetFileBlameInput, *RepositoryBlameOutput]( ToolsetMetadataRepos, mcp.Tool{ - Name: "get_file_blame", + Name: "get_file_blame", + OutputSchema: repositoryUnionSchema(&jsonschema.Schema{Type: "null"}, repositoryOutputSchema[BlameResult]()), Description: t("TOOL_GET_FILE_BLAME_DESCRIPTION", "Get git blame information for a file, showing the commit that last modified each line. "+ "Ranges share commit metadata via the top-level 'commits' map keyed by SHA. "+ @@ -2767,50 +2805,47 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { "start_line": { Type: "number", Description: "Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window.", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "end_line": { Type: "number", Description: "Optional 1-based ending line of the window of interest. Must be >= start_line when both are provided.", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, }, Required: []string{"owner", "repo", "path"}, }), }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input GetFileBlameInput) (*mcp.CallToolResult, *RepositoryBlameOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - path, err := RequiredParam[string](args, "path") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + path := input.Path + if path == "" { + return utils.NewToolResultError("missing required parameter: path"), nil, nil } if err := validateBlamePath(path); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - ref, err := OptionalParam[string](args, "ref") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - _, hasStartLine := args["start_line"] - startLine, err := OptionalIntParam(args, "start_line") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + ref := input.Ref + hasStartLine := input.StartLine != nil + startLine := 0 + if hasStartLine { + startLine = *input.StartLine } if hasStartLine && startLine < 1 { return utils.NewToolResultError("start_line must be omitted or >= 1"), nil, nil } - _, hasEndLine := args["end_line"] - endLine, err := OptionalIntParam(args, "end_line") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + hasEndLine := input.EndLine != nil + endLine := 0 + if hasEndLine { + endLine = *input.EndLine } if hasEndLine && endLine < 1 { return utils.NewToolResultError("end_line must be omitted or >= 1"), nil, nil @@ -2818,23 +2853,14 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { if hasStartLine && hasEndLine && endLine < startLine { return utils.NewToolResultError("end_line must be >= start_line when both are provided"), nil, nil } - if _, hasPage := args["page"]; hasPage { - return utils.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil, nil - } - pagination, err := OptionalCursorPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if _, hasPerPage := args["perPage"]; hasPerPage { - perPage, err := OptionalIntParam(args, "perPage") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if perPage < 1 || perPage > 100 { + pagination := CursorPaginationParams{After: input.After, PerPage: 30} + if input.PerPage != nil { + if *input.PerPage < 1 || *input.PerPage > 100 { return utils.NewToolResultError("perPage must be between 1 and 100 when provided"), nil, nil } - pagination.PerPage = perPage + pagination.PerPage = *input.PerPage } + afterOffset, err := decodeBlameCursor(pagination.After) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -2978,7 +3004,7 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { SHA: sha, // Sanitized after truncation so the headline is cut at the author's real // first line break rather than one introduced by sanitization. - MessageHeadline: sanitize.Sanitize(headline), + MessageHeadline: sanitize.PlainText(headline), CommittedDate: r.Commit.CommittedDate.Format("2006-01-02T15:04:05Z"), Author: BlameAuthor{ Name: string(r.Commit.Author.Name), @@ -3024,16 +3050,19 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(payload)), nil, nil + callResult := utils.NewToolResultText(string(payload)) + callResult = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, callResult, ifc.LabelCommitContents) + return callResult, &RepositoryBlameOutput{Result: &result}, nil }, + normalizeRepositoryBlameArguments, ) - st.FeatureFlagEnable = FeatureFlagFileBlame + st.FeatureRule = featureEnabledRule(FeatureFlagFileBlame) return st } // ListRepositoryCollaborators creates a tool to list collaborators of a GitHub repository. func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ListRepositoryCollaboratorsInput, *RepositoryCollaboratorsOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: "list_repository_collaborators", @@ -3068,23 +3097,17 @@ func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory }(), }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListRepositoryCollaboratorsInput) (*mcp.CallToolResult, *RepositoryCollaboratorsOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), 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 + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } + affiliation := input.Affiliation + pagination := repositoryPagination(input.Page, input.PerPage) client, err := deps.GetClient(ctx) if err != nil { @@ -3126,12 +3149,9 @@ func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory }) } - response := map[string]any{ - "items": result, - "nextPage": resp.NextPage, - "prevPage": resp.PrevPage, - "firstPage": resp.FirstPage, - "lastPage": resp.LastPage, + response := &RepositoryCollaboratorsOutput{ + Items: result, NextPage: resp.NextPage, PrevPage: resp.PrevPage, + FirstPage: resp.FirstPage, LastPage: resp.LastPage, } callResult := MarshalledTextResult(response) @@ -3140,7 +3160,8 @@ func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory // push access, so the roster is never world-readable — not even on // a public repo — hence always private confidentiality. callResult = attachStaticIFCLabel(ctx, deps, callResult, ifc.LabelCollaboratorRoster()) - return callResult, nil, nil + return callResult, response, nil }, + normalizeRepositoryArguments, ) } diff --git a/pkg/github/repositories_helper.go b/pkg/github/repositories_helper.go index 8859b4a2ba..62a4fcf755 100644 --- a/pkg/github/repositories_helper.go +++ b/pkg/github/repositories_helper.go @@ -12,10 +12,10 @@ import ( "strings" "unicode/utf8" - 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/v89/github" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/raw" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -34,9 +34,9 @@ func initializeRepository(ctx context.Context, client *github.Client, owner, rep defaultBranch := repository.GetDefaultBranch() fileOpts := &github.RepositoryContentFileOptions{ - Message: github.Ptr("Initial commit"), + Message: new("Initial commit"), Content: []byte(""), - Branch: github.Ptr(defaultBranch), + Branch: new(defaultBranch), } // Create an initial empty commit to create the default branch diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index a1ea5ff340..a054de6db7 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -10,14 +10,14 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/requeststate" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/inventory" - "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/v89/github" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/requeststate" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/raw" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -92,7 +92,6 @@ func Test_GetFileContents(t *testing.T) { // Verify tool definition once serverTool := GetFileContents(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") @@ -113,19 +112,19 @@ func Test_GetFileContents(t *testing.T) { // Setup mock directory content for success case mockDirContent := []*github.RepositoryContent{ { - Type: github.Ptr("file"), - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr(gitBlobSHA(mockRawContent)), - Size: github.Ptr(42), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"), + Type: new("file"), + Name: new("README.md"), + Path: new("README.md"), + SHA: new(gitBlobSHA(mockRawContent)), + Size: new(42), + HTMLURL: new("https://github.com/owner/repo/blob/main/README.md"), }, { - Type: github.Ptr("dir"), - Name: github.Ptr("src"), - Path: github.Ptr("src"), - SHA: github.Ptr("def456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/tree/main/src"), + Type: new("dir"), + Name: new("src"), + Path: new("src"), + SHA: new("def456"), + HTMLURL: new("https://github.com/owner/repo/tree/main/src"), }, } @@ -147,12 +146,12 @@ func Test_GetFileContents(t *testing.T) { GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr(gitBlobSHA(mockRawContent)), - Type: github.Ptr("file"), - Content: github.Ptr(string(mockRawContent)), - Size: github.Ptr(len(mockRawContent)), + Name: new("README.md"), + Path: new("README.md"), + SHA: new(gitBlobSHA(mockRawContent)), + Type: new("file"), + Content: new(string(mockRawContent)), + Size: new(len(mockRawContent)), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -170,6 +169,7 @@ func Test_GetFileContents(t *testing.T) { Text: "# Test Repository\n\nThis is a test repository.", MIMEType: "text/plain; charset=utf-8", }, + expectedMsg: "SHA: " + gitBlobSHA(mockRawContent), }, { name: "successful binary file content fetch (PNG)", @@ -182,13 +182,13 @@ func Test_GetFileContents(t *testing.T) { 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(gitBlobSHA(pngContent)), - Type: github.Ptr("file"), - Content: github.Ptr(encodedContent), - Size: github.Ptr(len(pngContent)), - Encoding: github.Ptr("base64"), + Name: new("test.png"), + Path: new("test.png"), + SHA: new(gitBlobSHA(pngContent)), + Type: new("file"), + Content: new(encodedContent), + Size: new(len(pngContent)), + Encoding: new("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -218,13 +218,13 @@ func Test_GetFileContents(t *testing.T) { 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(gitBlobSHA(pdfContent)), - Type: github.Ptr("file"), - Content: github.Ptr(encodedContent), - Size: github.Ptr(len(pdfContent)), - Encoding: github.Ptr("base64"), + Name: new("document.pdf"), + Path: new("document.pdf"), + SHA: new(gitBlobSHA(pdfContent)), + Type: new("file"), + Content: new(encodedContent), + Size: new(len(pdfContent)), + Encoding: new("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -273,13 +273,13 @@ func Test_GetFileContents(t *testing.T) { // 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(gitBlobSHA(mockRawContent)), - Type: github.Ptr("file"), - Content: github.Ptr(encodedContent), - Size: github.Ptr(len(mockRawContent)), - Encoding: github.Ptr("base64"), + Name: new("README.md"), + Path: new("README.md"), + SHA: new(gitBlobSHA(mockRawContent)), + Type: new("file"), + Content: new(encodedContent), + Size: new(len(mockRawContent)), + Encoding: new("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -357,13 +357,13 @@ func Test_GetFileContents(t *testing.T) { // 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(gitBlobSHA(mockRawContent)), - Type: github.Ptr("file"), - Content: github.Ptr(encodedContent), - Size: github.Ptr(len(mockRawContent)), - Encoding: github.Ptr("base64"), + Name: new("README.md"), + Path: new("README.md"), + SHA: new(gitBlobSHA(mockRawContent)), + Type: new("file"), + Content: new(encodedContent), + Size: new(len(mockRawContent)), + Encoding: new("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -390,22 +390,22 @@ func Test_GetFileContents(t *testing.T) { GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), "GET /repos/owner/repo/git/trees/refs/heads/main": mockResponse(t, http.StatusOK, &github.Tree{ Entries: []*github.TreeEntry{{ - Path: github.Ptr("large-file.bin"), - Mode: github.Ptr("100644"), - Type: github.Ptr("blob"), - SHA: github.Ptr(strings.Repeat("a", 40)), + Path: new("large-file.bin"), + Mode: new("100644"), + Type: new("blob"), + SHA: new(strings.Repeat("a", 40)), }}, }), 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(strings.Repeat("a", 40)), - Type: github.Ptr("file"), - Size: github.Ptr(2 * 1024 * 1024), // 2MB - DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/large-file.bin"), + Name: new("large-file.bin"), + Path: new("large-file.bin"), + SHA: new(strings.Repeat("a", 40)), + Type: new("file"), + Size: new(2 * 1024 * 1024), // 2MB + DownloadURL: new("https://raw.githubusercontent.com/owner/repo/main/large-file.bin"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -432,13 +432,13 @@ func Test_GetFileContents(t *testing.T) { 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(gitBlobSHA(nil)), - Type: github.Ptr("file"), + Name: new(".gitkeep"), + Path: new(".gitkeep"), + SHA: new(gitBlobSHA(nil)), + Type: new("file"), Content: nil, - Size: github.Ptr(0), - Encoding: github.Ptr("base64"), + Size: new(0), + Encoding: new("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -592,8 +592,8 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { content := []byte("ordinary content") result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("file"), Path: github.Ptr("README.md"), SHA: github.Ptr(gitBlobSHA(content)), - Content: github.Ptr(string(content)), Size: github.Ptr(len(content)), + Type: new("file"), Path: new("README.md"), SHA: new(gitBlobSHA(content)), + Content: new(string(content)), Size: new(len(content)), }, }, args("README.md")) require.False(t, result.IsError) @@ -614,9 +614,9 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { linkSHA := gitBlobSHA([]byte(target)) result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("file"), Path: github.Ptr("docs/link"), SHA: github.Ptr(linkSHA), - Content: github.Ptr(base64.StdEncoding.EncodeToString(tc.content)), - Encoding: github.Ptr("base64"), Size: github.Ptr(len(tc.content)), + Type: new("file"), Path: new("docs/link"), SHA: new(linkSHA), + Content: new(base64.StdEncoding.EncodeToString(tc.content)), + Encoding: new("base64"), Size: new(len(tc.content)), }, blobs: map[string][]byte{linkSHA: []byte(target)}, }, args("docs/link")) @@ -625,6 +625,7 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { metadata := repositoryPathMetadataFromResult(t, result) assert.Equal(t, "symlink", metadata.Type) assert.Equal(t, "docs/link", metadata.Path) + assert.Equal(t, linkSHA, metadata.SHA) assert.Equal(t, target, metadata.Target) assert.Equal(t, "target/"+tc.name, metadata.ResolvedTargetPath) assert.Equal(t, "dereferenced_target", metadata.Content) @@ -642,13 +643,14 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { target := "../../outside" result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("symlink"), Path: github.Ptr("docs/link"), - SHA: github.Ptr(gitBlobSHA([]byte(target))), Target: github.Ptr(target), + Type: new("symlink"), Path: new("docs/link"), + SHA: new(gitBlobSHA([]byte(target))), Target: new(target), }, }, args("docs/link")) require.False(t, result.IsError) assert.Equal(t, 1, requests) metadata := repositoryPathMetadataFromResult(t, result) + assert.Equal(t, gitBlobSHA([]byte(target)), metadata.SHA) assert.Equal(t, target, metadata.Target) assert.Empty(t, metadata.ResolvedTargetPath) assert.Equal(t, "not_returned", metadata.Content) @@ -659,9 +661,9 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { target, content := "target.txt", []byte("resolved") result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("symlink"), Path: github.Ptr("docs/link"), - SHA: github.Ptr(gitBlobSHA([]byte(target))), Target: github.Ptr(target), - Content: github.Ptr(string(content)), Size: github.Ptr(len(content)), + Type: new("symlink"), Path: new("docs/link"), + SHA: new(gitBlobSHA([]byte(target))), Target: new(target), + Content: new(string(content)), Size: new(len(content)), }, }, args("docs/link")) require.False(t, result.IsError) @@ -673,8 +675,8 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { t.Run("submodule is explicit", func(t *testing.T) { result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("submodule"), Path: github.Ptr("vendor/module"), - SHA: github.Ptr(strings.Repeat("d", 40)), SubmoduleGitURL: github.Ptr("https://example.com/module.git"), + Type: new("submodule"), Path: new("vendor/module"), + SHA: new(strings.Repeat("d", 40)), SubmoduleGitURL: new("https://example.com/module.git"), }, }, args("vendor/module")) require.False(t, result.IsError) @@ -688,8 +690,8 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { content := []byte("resolved") result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("file"), Path: github.Ptr("bad"), SHA: github.Ptr("bad-sha"), - Content: github.Ptr(string(content)), Size: github.Ptr(len(content)), + Type: new("file"), Path: new("bad"), SHA: new("bad-sha"), + Content: new(string(content)), Size: new(len(content)), }, }, args("bad")) assert.Equal(t, 1, requests) @@ -699,8 +701,8 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { pathSHA := gitBlobSHA(pathBlob) result, requests = runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("file"), Path: github.Ptr("bad"), SHA: github.Ptr(pathSHA), - Content: github.Ptr(string(content)), Size: github.Ptr(len(content)), + Type: new("file"), Path: new("bad"), SHA: new(pathSHA), + Content: new(string(content)), Size: new(len(content)), }, blobs: map[string][]byte{pathSHA: pathBlob}, }, args("bad")) @@ -720,10 +722,10 @@ func Test_GetFileContents_ContentlessRequestCounts(t *testing.T) { t.Run("normal large file", func(t *testing.T) { result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("file"), Path: github.Ptr("large.bin"), SHA: github.Ptr(fileSHA), Size: github.Ptr(largeSize), + Type: new("file"), Path: new("large.bin"), SHA: new(fileSHA), Size: new(largeSize), }, trees: map[string]*github.Tree{commitSHA: {Entries: []*github.TreeEntry{ - {Path: github.Ptr("large.bin"), Mode: github.Ptr("100644"), Type: github.Ptr("blob"), SHA: github.Ptr(fileSHA)}, + {Path: new("large.bin"), Mode: new("100644"), Type: new("blob"), SHA: new(fileSHA)}, }}}, }, args("large.bin")) require.False(t, result.IsError) @@ -737,15 +739,15 @@ func Test_GetFileContents_ContentlessRequestCounts(t *testing.T) { linkSHA := gitBlobSHA([]byte(target)) result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("file"), Path: github.Ptr("docs/link"), SHA: github.Ptr(linkSHA), Size: github.Ptr(largeSize), + Type: new("file"), Path: new("docs/link"), SHA: new(linkSHA), Size: new(largeSize), }, blobs: map[string][]byte{linkSHA: []byte(target)}, trees: map[string]*github.Tree{ commitSHA: {Entries: []*github.TreeEntry{ - {Path: github.Ptr("docs"), Mode: github.Ptr("040000"), Type: github.Ptr("tree"), SHA: github.Ptr("docs-tree")}, + {Path: new("docs"), Mode: new("040000"), Type: new("tree"), SHA: new("docs-tree")}, }}, "docs-tree": {Entries: []*github.TreeEntry{ - {Path: github.Ptr("link"), Mode: github.Ptr(gitSymlinkMode), Type: github.Ptr("blob"), SHA: github.Ptr(linkSHA)}, + {Path: new("link"), Mode: new(gitSymlinkMode), Type: new("blob"), SHA: new(linkSHA)}, }}, }, inspect: func(r *http.Request) { @@ -764,9 +766,9 @@ func Test_GetFileContents_ContentlessRequestCounts(t *testing.T) { t.Run("truncated tree fails closed", func(t *testing.T) { result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("file"), Path: github.Ptr("large.bin"), SHA: github.Ptr(fileSHA), Size: github.Ptr(largeSize), + Type: new("file"), Path: new("large.bin"), SHA: new(fileSHA), Size: new(largeSize), }, - trees: map[string]*github.Tree{commitSHA: {Truncated: github.Ptr(true)}}, + trees: map[string]*github.Tree{commitSHA: {Truncated: new(true)}}, }, args("large.bin")) assert.Equal(t, 2, requests) assert.Contains(t, getErrorResult(t, result).Text, "truncated") @@ -775,7 +777,7 @@ func Test_GetFileContents_ContentlessRequestCounts(t *testing.T) { t.Run("directory remains one request", func(t *testing.T) { result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: []*github.RepositoryContent{{ - Type: github.Ptr("file"), Path: github.Ptr("docs/readme"), SHA: github.Ptr(fileSHA), + Type: new("file"), Path: new("docs/readme"), SHA: new(fileSHA), }}, }, args("docs")) require.False(t, result.IsError) @@ -786,21 +788,21 @@ func Test_GetFileContents_ContentlessRequestCounts(t *testing.T) { func Test_GetFileContents_DirectoryFieldFiltering(t *testing.T) { mockDirContent := []*github.RepositoryContent{ { - Type: github.Ptr("file"), - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Size: github.Ptr(42), - URL: github.Ptr("https://api.github.com/repos/owner/repo/contents/README.md"), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"), - DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/README.md"), + Type: new("file"), + Name: new("README.md"), + Path: new("README.md"), + SHA: new("abc123"), + Size: new(42), + URL: new("https://api.github.com/repos/owner/repo/contents/README.md"), + HTMLURL: new("https://github.com/owner/repo/blob/main/README.md"), + DownloadURL: new("https://raw.githubusercontent.com/owner/repo/main/README.md"), }, { - Type: github.Ptr("dir"), - Name: github.Ptr("src"), - Path: github.Ptr("src"), - SHA: github.Ptr("def456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/tree/main/src"), + Type: new("dir"), + Name: new("src"), + Path: new("src"), + SHA: new("def456"), + HTMLURL: new("https://github.com/owner/repo/tree/main/src"), }, } @@ -849,14 +851,14 @@ func Test_GetFileContents_DirectoryFieldFiltering(t *testing.T) { func Test_GetFileContents_DirectoryFieldsTelemetry(t *testing.T) { mockDirContent := []*github.RepositoryContent{ { - Type: github.Ptr("file"), - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Size: github.Ptr(42), - URL: github.Ptr("https://api.github.com/repos/owner/repo/contents/README.md"), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"), - DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/README.md"), + Type: new("file"), + Name: new("README.md"), + Path: new("README.md"), + SHA: new("abc123"), + Size: new(42), + URL: new("https://api.github.com/repos/owner/repo/contents/README.md"), + HTMLURL: new("https://github.com/owner/repo/blob/main/README.md"), + DownloadURL: new("https://raw.githubusercontent.com/owner/repo/main/README.md"), }, } @@ -916,13 +918,13 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { 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(gitBlobSHA(mockRawContent)), - Type: github.Ptr("file"), - Content: github.Ptr(encodedContent), - Size: github.Ptr(len(mockRawContent)), - Encoding: github.Ptr("base64"), + Name: new("README.md"), + Path: new("README.md"), + SHA: new(gitBlobSHA(mockRawContent)), + Type: new("file"), + Content: new(encodedContent), + Size: new(len(mockRawContent)), + Encoding: new("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -1009,13 +1011,13 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { 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(gitBlobSHA(mockRawContent)), - Type: github.Ptr("file"), - Content: github.Ptr(encodedContent), - Size: github.Ptr(len(mockRawContent)), - Encoding: github.Ptr("base64"), + Name: new("README.md"), + Path: new("README.md"), + SHA: new(gitBlobSHA(mockRawContent)), + Type: new("file"), + Content: new(encodedContent), + Size: new(len(mockRawContent)), + Encoding: new("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -1045,8 +1047,8 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{"private": false}), GetReposContentsByOwnerByRepoByPath: mockResponse(t, http.StatusOK, &github.RepositoryContent{ - Type: github.Ptr("file"), Path: github.Ptr("link"), SHA: github.Ptr(linkSHA), - Content: github.Ptr(string(content)), Size: github.Ptr(len(content)), + Type: new("file"), Path: new("link"), SHA: new(linkSHA), + Content: new(string(content)), Size: new(len(content)), }), GetReposGitBlobsByOwnerByRepoByFileSHA: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(target) @@ -1078,9 +1080,9 @@ func Test_GetCommit_IFC_FeatureFlag(t *testing.T) { serverTool := GetCommit(translations.NullTranslationHelper) mockCommit := &github.RepositoryCommit{ - SHA: github.Ptr("abc123def456"), - Commit: &github.Commit{Message: github.Ptr("First commit")}, - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + SHA: new("abc123def456"), + Commit: &github.Commit{Message: new("First commit")}, + HTMLURL: new("https://github.com/owner/repo/commit/abc123def456"), } makeMockClient := func(isPrivate bool) *http.Client { @@ -1190,7 +1192,6 @@ func Test_ForkRepository(t *testing.T) { // Verify tool definition once serverTool := ForkRepository(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") @@ -1204,16 +1205,16 @@ func Test_ForkRepository(t *testing.T) { // Setup mock forked repo for success case mockForkedRepo := &github.Repository{ - ID: github.Ptr(int64(123456)), - Name: github.Ptr("repo"), - FullName: github.Ptr("new-owner/repo"), + ID: new(int64(123456)), + Name: new("repo"), + FullName: new("new-owner/repo"), Owner: &github.User{ - Login: github.Ptr("new-owner"), + Login: new("new-owner"), }, - HTMLURL: github.Ptr("https://github.com/new-owner/repo"), - DefaultBranch: github.Ptr("main"), - Fork: github.Ptr(true), - ForksCount: github.Ptr(0), + HTMLURL: new("https://github.com/new-owner/repo"), + DefaultBranch: new("main"), + Fork: new(true), + ForksCount: new(0), } tests := []struct { @@ -1292,7 +1293,6 @@ func Test_CreateBranch(t *testing.T) { // Verify tool definition once serverTool := CreateBranch(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") @@ -1307,22 +1307,22 @@ func Test_CreateBranch(t *testing.T) { // Setup mock repository for default branch test mockRepo := &github.Repository{ - DefaultBranch: github.Ptr("main"), + DefaultBranch: new("main"), } // Setup mock reference for from_branch tests mockSourceRef := &github.Reference{ - Ref: github.Ptr("refs/heads/main"), + Ref: new("refs/heads/main"), Object: &github.GitObject{ - SHA: github.Ptr("abc123def456"), + SHA: new("abc123def456"), }, } // Setup mock created reference mockCreatedRef := &github.Reference{ - Ref: github.Ptr("refs/heads/new-feature"), + Ref: new("refs/heads/new-feature"), Object: &github.GitObject{ - SHA: github.Ptr("abc123def456"), + SHA: new("abc123def456"), }, } @@ -1505,7 +1505,6 @@ func Test_GetCommit(t *testing.T) { // Verify tool definition once serverTool := GetCommit(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") @@ -1518,32 +1517,32 @@ func Test_GetCommit(t *testing.T) { assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "sha"}) mockCommit := &github.RepositoryCommit{ - SHA: github.Ptr("abc123def456"), + SHA: new("abc123def456"), Commit: &github.Commit{ - Message: github.Ptr("First commit"), + Message: new("First commit"), Author: &github.CommitAuthor{ - Name: github.Ptr("Test User"), - Email: github.Ptr("test@example.com"), + Name: new("Test User"), + Email: new("test@example.com"), Date: &github.Timestamp{Time: time.Now().Add(-48 * time.Hour)}, }, }, Author: &github.User{ - Login: github.Ptr("testuser"), + Login: new("testuser"), }, - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + HTMLURL: new("https://github.com/owner/repo/commit/abc123def456"), Stats: &github.CommitStats{ - Additions: github.Ptr(10), - Deletions: github.Ptr(2), - Total: github.Ptr(12), + Additions: new(10), + Deletions: new(2), + Total: new(12), }, Files: []*github.CommitFile{ { - Filename: github.Ptr("file1.go"), - Status: github.Ptr("modified"), - Additions: github.Ptr(10), - Deletions: github.Ptr(2), - Changes: github.Ptr(12), - Patch: github.Ptr("@@ -1,2 +1,10 @@"), + Filename: new("file1.go"), + Status: new("modified"), + Additions: new(10), + Deletions: new(2), + Changes: new(12), + Patch: new("@@ -1,2 +1,10 @@"), }, }, } @@ -1632,24 +1631,24 @@ func Test_GetCommit(t *testing.T) { func Test_GetCommit_Detail(t *testing.T) { mockCommit := &github.RepositoryCommit{ - SHA: github.Ptr("abc123def456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + SHA: new("abc123def456"), + HTMLURL: new("https://github.com/owner/repo/commit/abc123def456"), Commit: &github.Commit{ - Message: github.Ptr("First commit"), + Message: new("First commit"), }, Stats: &github.CommitStats{ - Additions: github.Ptr(10), - Deletions: github.Ptr(2), - Total: github.Ptr(12), + Additions: new(10), + Deletions: new(2), + Total: new(12), }, Files: []*github.CommitFile{ { - Filename: github.Ptr("file1.go"), - Status: github.Ptr("modified"), - Additions: github.Ptr(10), - Deletions: github.Ptr(2), - Changes: github.Ptr(12), - Patch: github.Ptr("@@ -1,2 +1,10 @@\n+new line"), + Filename: new("file1.go"), + Status: new("modified"), + Additions: new(10), + Deletions: new(2), + Changes: new(12), + Patch: new("@@ -1,2 +1,10 @@\n+new line"), }, }, } @@ -1748,7 +1747,6 @@ func Test_ListCommits(t *testing.T) { // Verify tool definition once serverTool := ListCommits(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") @@ -1770,73 +1768,73 @@ func Test_ListCommits(t *testing.T) { // Setup mock commits for success case mockCommits := []*github.RepositoryCommit{ { - SHA: github.Ptr("abc123def456"), + SHA: new("abc123def456"), Commit: &github.Commit{ - Message: github.Ptr("First commit"), + Message: new("First commit"), Author: &github.CommitAuthor{ - Name: github.Ptr("Test User"), - Email: github.Ptr("test@example.com"), + Name: new("Test User"), + Email: new("test@example.com"), Date: &github.Timestamp{Time: time.Now().Add(-48 * time.Hour)}, }, }, Author: &github.User{ - Login: github.Ptr("testuser"), - ID: github.Ptr(int64(12345)), - HTMLURL: github.Ptr("https://github.com/testuser"), - AvatarURL: github.Ptr("https://github.com/testuser.png"), + Login: new("testuser"), + ID: new(int64(12345)), + HTMLURL: new("https://github.com/testuser"), + AvatarURL: new("https://github.com/testuser.png"), }, - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + HTMLURL: new("https://github.com/owner/repo/commit/abc123def456"), Stats: &github.CommitStats{ - Additions: github.Ptr(10), - Deletions: github.Ptr(5), - Total: github.Ptr(15), + Additions: new(10), + Deletions: new(5), + Total: new(15), }, Files: []*github.CommitFile{ { - Filename: github.Ptr("src/main.go"), - Status: github.Ptr("modified"), - Additions: github.Ptr(8), - Deletions: github.Ptr(3), - Changes: github.Ptr(11), + Filename: new("src/main.go"), + Status: new("modified"), + Additions: new(8), + Deletions: new(3), + Changes: new(11), }, { - Filename: github.Ptr("README.md"), - Status: github.Ptr("added"), - Additions: github.Ptr(2), - Deletions: github.Ptr(2), - Changes: github.Ptr(4), + Filename: new("README.md"), + Status: new("added"), + Additions: new(2), + Deletions: new(2), + Changes: new(4), }, }, }, { - SHA: github.Ptr("def456abc789"), + SHA: new("def456abc789"), Commit: &github.Commit{ - Message: github.Ptr("Second commit"), + Message: new("Second commit"), Author: &github.CommitAuthor{ - Name: github.Ptr("Another User"), - Email: github.Ptr("another@example.com"), + Name: new("Another User"), + Email: new("another@example.com"), Date: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, }, }, Author: &github.User{ - Login: github.Ptr("anotheruser"), - ID: github.Ptr(int64(67890)), - HTMLURL: github.Ptr("https://github.com/anotheruser"), - AvatarURL: github.Ptr("https://github.com/anotheruser.png"), + Login: new("anotheruser"), + ID: new(int64(67890)), + HTMLURL: new("https://github.com/anotheruser"), + AvatarURL: new("https://github.com/anotheruser.png"), }, - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), + HTMLURL: new("https://github.com/owner/repo/commit/def456abc789"), Stats: &github.CommitStats{ - Additions: github.Ptr(20), - Deletions: github.Ptr(10), - Total: github.Ptr(30), + Additions: new(20), + Deletions: new(10), + Total: new(30), }, Files: []*github.CommitFile{ { - Filename: github.Ptr("src/utils.go"), - Status: github.Ptr("added"), - Additions: github.Ptr(20), - Deletions: github.Ptr(10), - Changes: github.Ptr(30), + Filename: new("src/utils.go"), + Status: new("added"), + Additions: new(20), + Deletions: new(10), + Changes: new(30), }, }, }, @@ -2050,13 +2048,15 @@ func Test_CreateOrUpdateFile(t *testing.T) { // Verify tool definition once serverTool := CreateOrUpdateFile(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, "create_or_update_file", tool.Name) assert.NotEmpty(t, tool.Description) + assert.NotContains(t, tool.Description, "git rev-parse") + assert.Contains(t, tool.Description, "get_file_contents") + assert.Contains(t, tool.Description, "set its ref parameter to this tool's branch value") assert.Contains(t, schema.Properties, "owner") assert.Contains(t, schema.Properties, "repo") assert.Contains(t, schema.Properties, "path") @@ -2065,27 +2065,28 @@ func Test_CreateOrUpdateFile(t *testing.T) { assert.Contains(t, schema.Properties, "branch") assert.Contains(t, schema.Properties, "sha") assert.Contains(t, schema.Properties, "allow_symlink_write") + assert.Contains(t, schema.Properties["sha"].Description, "with ref set to this tool's branch value") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "path", "content", "message", "branch"}) // Setup mock file content response mockFileResponse := &github.RepositoryContentResponse{ Content: &github.RepositoryContent{ - Name: github.Ptr("example.md"), - Path: github.Ptr("docs/example.md"), - SHA: github.Ptr("abc123def456"), - Size: github.Ptr(42), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/docs/example.md"), - DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/docs/example.md"), + Name: new("example.md"), + Path: new("docs/example.md"), + SHA: new("abc123def456"), + Size: new(42), + HTMLURL: new("https://github.com/owner/repo/blob/main/docs/example.md"), + DownloadURL: new("https://raw.githubusercontent.com/owner/repo/main/docs/example.md"), }, Commit: github.Commit{ - SHA: github.Ptr("def456abc789"), - Message: github.Ptr("Add example file"), + SHA: new("def456abc789"), + Message: new("Add example file"), Author: &github.CommitAuthor{ - Name: github.Ptr("Test User"), - Email: github.Ptr("test@example.com"), + Name: new("Test User"), + Email: new("test@example.com"), Date: &github.Timestamp{Time: time.Now()}, }, - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), + HTMLURL: new("https://github.com/owner/repo/commit/def456abc789"), }, } symlinkTarget := []byte("other.md") @@ -2098,10 +2099,10 @@ func Test_CreateOrUpdateFile(t *testing.T) { tree = &github.Tree{ Entries: []*github.TreeEntry{ { - Path: github.Ptr("docs"), - Mode: github.Ptr("040000"), - Type: github.Ptr("tree"), - SHA: github.Ptr("docs-tree"), + Path: new("docs"), + Mode: new("040000"), + Type: new("tree"), + SHA: new("docs-tree"), }, }, } @@ -2113,10 +2114,10 @@ func Test_CreateOrUpdateFile(t *testing.T) { tree = &github.Tree{ Entries: []*github.TreeEntry{ { - Path: github.Ptr("example.md"), - Mode: github.Ptr(mode), - Type: github.Ptr("blob"), - SHA: github.Ptr(entrySHA), + Path: new("example.md"), + Mode: new(mode), + Type: new("blob"), + SHA: new(entrySHA), }, }, } @@ -2135,6 +2136,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { expectedContent *github.RepositoryContentResponse expectedErrMsg string expectedErrMsgs []string + unexpectedErrMsgs []string expectedRequestCount int }{ { @@ -2172,12 +2174,12 @@ func Test_CreateOrUpdateFile(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposGitTreesByOwnerByRepoByTree: mockPathTree("100644"), "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ "message": "Update example file", @@ -2238,12 +2240,12 @@ func Test_CreateOrUpdateFile(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposGitTreesByOwnerByRepoByTree: mockPathTree("100644"), "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ "message": "Update example file", @@ -2283,12 +2285,12 @@ func Test_CreateOrUpdateFile(t *testing.T) { _, _ = w.Write(symlinkTarget) }, "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), }), requestArgs: map[string]any{ @@ -2323,12 +2325,12 @@ func Test_CreateOrUpdateFile(t *testing.T) { _, _ = w.Write(symlinkTarget) }, "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), }), requestArgs: map[string]any{ @@ -2348,12 +2350,12 @@ func Test_CreateOrUpdateFile(t *testing.T) { name: "allows intentional symbolic link update with explicit opt-in", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ "message": "Change link target", @@ -2390,14 +2392,14 @@ func Test_CreateOrUpdateFile(t *testing.T) { name: "rejects explicit symbolic link response without tree inspection", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("symlink"), - Target: github.Ptr("../../outside"), + SHA: new("abc123def456"), + Type: new("symlink"), + Target: new("../../outside"), }), "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("symlink"), - Target: github.Ptr("../../outside"), + SHA: new("abc123def456"), + Type: new("symlink"), + Target: new("../../outside"), }), }), requestArgs: map[string]any{ @@ -2422,15 +2424,15 @@ func Test_CreateOrUpdateFile(t *testing.T) { name: "fails closed when git tree is truncated", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposGitTreesByOwnerByRepoByTree: mockResponse(t, http.StatusOK, &github.Tree{ - Truncated: github.Ptr(true), + Truncated: new(true), }), "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), }), requestArgs: map[string]any{ @@ -2449,26 +2451,60 @@ func Test_CreateOrUpdateFile(t *testing.T) { { name: "sha validation - stale sha detected", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "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/docs/example.md": expectQueryParams(t, map[string]string{ + "ref": "main", + }).andThen(mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: new(symlinkSHA), + Type: new("symlink"), + Target: new(string(symlinkTarget)), + })), + "GET /repos/{owner}/{repo}/contents/{path:.*}": expectQueryParams(t, map[string]string{ + "ref": "main", + }).andThen(mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: new(symlinkSHA), + Type: new("symlink"), + Target: new(string(symlinkTarget)), + })), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "docs/example.md", + "content": "# Updated Example\n\nThis file has been updated.", + "message": "Update example file", + "branch": "main", + "sha": "oldsha123456", + }, + expectError: true, + expectedErrMsgs: []string{ + "SHA mismatch: provided SHA oldsha123456 is stale. Current file SHA is " + symlinkSHA, + `Call get_file_contents with owner="owner", repo="repo", path="docs/example.md", and ref="main"`, + "its first text result reports the blob SHA for the requested path", + "retry with the sha parameter set to the SHA that call reports", + }, + expectedRequestCount: 1, + }, + { + name: "sha validation - api error is surfaced", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusForbidden, map[string]any{ + "message": "Resource not accessible", }), - "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("newsha999888"), - Type: github.Ptr("file"), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusForbidden, map[string]any{ + "message": "Resource not accessible", }), }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "docs/example.md", - "content": "# Updated Example\n\nThis file has been updated.", + "content": "updated", "message": "Update example file", "branch": "main", "sha": "oldsha123456", }, expectError: true, - expectedErrMsg: "SHA mismatch: provided SHA oldsha123456 is stale. Current file SHA is newsha999888", + expectedErrMsg: "failed to verify file SHA", expectedRequestCount: 1, }, { @@ -2513,14 +2549,18 @@ func Test_CreateOrUpdateFile(t *testing.T) { { name: "no sha provided - file exists, rejects update", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "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"), - }), + "GET /repos/owner/repo/contents/docs/example.md": expectQueryParams(t, map[string]string{ + "ref": "release/#candidate", + }).andThen(mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: new("existing123"), + Type: new("file"), + })), + "GET /repos/{owner}/{repo}/contents/{path:.*}": expectQueryParams(t, map[string]string{ + "ref": "release/#candidate", + }).andThen(mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: new("existing123"), + Type: new("file"), + })), }), requestArgs: map[string]any{ "owner": "owner", @@ -2528,10 +2568,40 @@ func Test_CreateOrUpdateFile(t *testing.T) { "path": "docs/example.md", "content": "# Updated\n\nUpdated without SHA.", "message": "Update without SHA", + "branch": "release/#candidate", + }, + expectError: true, + expectedErrMsgs: []string{ + "File already exists at docs/example.md", + `Call get_file_contents with owner="owner", repo="repo", path="docs/example.md", and ref="release/#candidate"`, + "its first text result reports the blob SHA for the requested path", + "retry with the sha parameter set to the blob SHA that call reports", + }, + // A caller that never supplied a SHA has not read this file, so the + // error must not hand it one to overwrite with. + unexpectedErrMsgs: []string{"existing123"}, + expectedRequestCount: 1, + }, + { + name: "no sha provided - api error is surfaced", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusInternalServerError, map[string]any{ + "message": "Internal Server Error", + }), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusInternalServerError, map[string]any{ + "message": "Internal Server Error", + }), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "docs/example.md", + "content": "updated", + "message": "Update example file", "branch": "main", }, expectError: true, - expectedErrMsg: "File already exists at docs/example.md", + expectedErrMsg: "failed to check if file exists", expectedRequestCount: 1, }, { @@ -2606,6 +2676,12 @@ func Test_CreateOrUpdateFile(t *testing.T) { for _, expectedErrMsg := range tc.expectedErrMsgs { assert.Contains(t, errorText.String(), expectedErrMsg) } + for _, unexpectedErrMsg := range tc.unexpectedErrMsgs { + assert.NotContains(t, errorText.String(), unexpectedErrMsg) + } + // The caller of this tool works over the API and has no working + // tree, so errors must never ask it to run a local git command. + assert.NotContains(t, errorText.String(), "git rev-parse") return } @@ -2651,7 +2727,6 @@ func Test_CreateRepository(t *testing.T) { // Verify tool definition once serverTool := CreateRepository(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") @@ -2667,13 +2742,13 @@ func Test_CreateRepository(t *testing.T) { // Setup mock repository response mockRepo := &github.Repository{ - Name: github.Ptr("test-repo"), - Description: github.Ptr("Test repository"), - Private: github.Ptr(true), - HTMLURL: github.Ptr("https://github.com/testuser/test-repo"), + Name: new("test-repo"), + Description: new("Test repository"), + Private: new(true), + HTMLURL: new("https://github.com/testuser/test-repo"), CreatedAt: &github.Timestamp{Time: time.Now()}, Owner: &github.User{ - Login: github.Ptr("testuser"), + Login: new("testuser"), }, } @@ -2841,7 +2916,6 @@ func Test_PushFiles(t *testing.T) { // Verify tool definition once serverTool := PushFiles(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") @@ -2857,35 +2931,35 @@ func Test_PushFiles(t *testing.T) { // Setup mock objects mockRef := &github.Reference{ - Ref: github.Ptr("refs/heads/main"), + Ref: new("refs/heads/main"), Object: &github.GitObject{ - SHA: github.Ptr("abc123"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/git/trees/abc123"), + SHA: new("abc123"), + URL: new("https://api.github.com/repos/owner/repo/git/trees/abc123"), }, } mockCommit := &github.Commit{ - SHA: github.Ptr("abc123"), + SHA: new("abc123"), Tree: &github.Tree{ - SHA: github.Ptr("def456"), + SHA: new("def456"), }, } mockTree := &github.Tree{ - SHA: github.Ptr("ghi789"), + SHA: new("ghi789"), } mockNewCommit := &github.Commit{ - SHA: github.Ptr("jkl012"), - Message: github.Ptr("Update multiple files"), - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"), + SHA: new("jkl012"), + Message: new("Update multiple files"), + HTMLURL: new("https://github.com/owner/repo/commit/jkl012"), } mockUpdatedRef := &github.Reference{ - Ref: github.Ptr("refs/heads/main"), + Ref: new("refs/heads/main"), Object: &github.GitObject{ - SHA: github.Ptr("jkl012"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/git/trees/jkl012"), + SHA: new("jkl012"), + URL: new("https://api.github.com/repos/owner/repo/git/trees/jkl012"), }, } @@ -3168,7 +3242,7 @@ func Test_PushFiles(t *testing.T) { WithRequestMatch( GetReposByOwnerByRepo, &github.Repository{ - DefaultBranch: github.Ptr("main"), + DefaultBranch: new("main"), }, ), // Create initial file using Contents API @@ -3182,7 +3256,7 @@ func Test_PushFiles(t *testing.T) { require.Equal(t, "main", body["branch"]) w.WriteHeader(http.StatusCreated) response := &github.RepositoryContentResponse{ - Commit: github.Commit{SHA: github.Ptr("abc123")}, + Commit: github.Commit{SHA: new("abc123")}, } b, _ := json.Marshal(response) _, _ = w.Write(b) @@ -3246,8 +3320,8 @@ func Test_PushFiles(t *testing.T) { // Second call: returns the updated reference after first file creation w.WriteHeader(http.StatusOK) b, _ := json.Marshal(&github.Reference{ - Ref: github.Ptr("refs/heads/main"), - Object: &github.GitObject{SHA: github.Ptr("init456")}, + Ref: new("refs/heads/main"), + Object: &github.GitObject{SHA: new("init456")}, }) _, _ = w.Write(b) } @@ -3258,7 +3332,7 @@ func Test_PushFiles(t *testing.T) { WithRequestMatch( GetReposByOwnerByRepo, &github.Repository{ - DefaultBranch: github.Ptr("main"), + DefaultBranch: new("main"), }, ), // Create initial empty README.md file using Contents API to initialize repo @@ -3276,12 +3350,12 @@ func Test_PushFiles(t *testing.T) { w.WriteHeader(http.StatusCreated) response := &github.RepositoryContentResponse{ Content: &github.RepositoryContent{ - SHA: github.Ptr("readme123"), + SHA: new("readme123"), }, Commit: github.Commit{ - SHA: github.Ptr("init456"), + SHA: new("init456"), Tree: &github.Tree{ - SHA: github.Ptr("tree456"), + SHA: new("tree456"), }, }, } @@ -3295,9 +3369,9 @@ func Test_PushFiles(t *testing.T) { http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) response := &github.Commit{ - SHA: github.Ptr("init456"), + SHA: new("init456"), Tree: &github.Tree{ - SHA: github.Ptr("tree456"), + SHA: new("tree456"), }, } b, _ := json.Marshal(response) @@ -3397,7 +3471,7 @@ func Test_PushFiles(t *testing.T) { WithRequestMatch( GetReposByOwnerByRepo, &github.Repository{ - DefaultBranch: github.Ptr("main"), + DefaultBranch: new("main"), }, ), // Fail to create initial file using Contents API @@ -3450,15 +3524,15 @@ func Test_PushFiles(t *testing.T) { WithRequestMatch( GetReposByOwnerByRepo, &github.Repository{ - DefaultBranch: github.Ptr("main"), + DefaultBranch: new("main"), }, ), // Create initial file using Contents API WithRequestMatch( PutReposContentsByOwnerByRepoByPath, &github.RepositoryContentResponse{ - Content: &github.RepositoryContent{SHA: github.Ptr("readme123")}, - Commit: github.Commit{SHA: github.Ptr("init456")}, + Content: &github.RepositoryContent{SHA: new("readme123")}, + Commit: github.Commit{SHA: new("init456")}, }, ), ), @@ -3496,15 +3570,15 @@ func Test_PushFiles(t *testing.T) { WithRequestMatch( GetReposByOwnerByRepo, &github.Repository{ - DefaultBranch: github.Ptr("main"), + DefaultBranch: new("main"), }, ), // Create initial file using Contents API WithRequestMatch( PutReposContentsByOwnerByRepoByPath, &github.RepositoryContentResponse{ - Content: &github.RepositoryContent{SHA: github.Ptr("readme123")}, - Commit: github.Commit{SHA: github.Ptr("init456")}, + Content: &github.RepositoryContent{SHA: new("readme123")}, + Commit: github.Commit{SHA: new("init456")}, }, ), // Fail to get commit @@ -3586,7 +3660,6 @@ func Test_PushFiles(t *testing.T) { func Test_DeleteRepository(t *testing.T) { serverTool := DeleteRepository(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") @@ -4040,12 +4113,12 @@ func Test_ListBranches(t *testing.T) { // Setup mock branches for success case mockBranches := []*github.Branch{ { - Name: github.Ptr("main"), - Commit: &github.RepositoryCommit{SHA: github.Ptr("abc123")}, + Name: new("main"), + Commit: &github.RepositoryCommit{SHA: new("abc123")}, }, { - Name: github.Ptr("develop"), - Commit: &github.RepositoryCommit{SHA: github.Ptr("def456")}, + Name: new("develop"), + Commit: &github.RepositoryCommit{SHA: new("def456")}, }, } @@ -4142,7 +4215,6 @@ func Test_DeleteFile(t *testing.T) { // Verify tool definition once serverTool := DeleteFile(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") @@ -4159,27 +4231,27 @@ func Test_DeleteFile(t *testing.T) { // Setup mock objects for Git Data API mockRef := &github.Reference{ - Ref: github.Ptr("refs/heads/main"), + Ref: new("refs/heads/main"), Object: &github.GitObject{ - SHA: github.Ptr("abc123"), + SHA: new("abc123"), }, } mockCommit := &github.Commit{ - SHA: github.Ptr("abc123"), + SHA: new("abc123"), Tree: &github.Tree{ - SHA: github.Ptr("def456"), + SHA: new("def456"), }, } mockTree := &github.Tree{ - SHA: github.Ptr("ghi789"), + SHA: new("ghi789"), } mockNewCommit := &github.Commit{ - SHA: github.Ptr("jkl012"), - Message: github.Ptr("Delete example file"), - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"), + SHA: new("jkl012"), + Message: new("Delete example file"), + HTMLURL: new("https://github.com/owner/repo/commit/jkl012"), } tests := []struct { @@ -4239,9 +4311,9 @@ func Test_DeleteFile(t *testing.T) { "force": false, }).andThen( mockResponse(t, http.StatusOK, &github.Reference{ - Ref: github.Ptr("refs/heads/main"), + Ref: new("refs/heads/main"), Object: &github.GitObject{ - SHA: github.Ptr("jkl012"), + SHA: new("jkl012"), }, }), ), @@ -4340,22 +4412,22 @@ func Test_ListTags(t *testing.T) { // Setup mock tags for success case mockTags := []*github.RepositoryTag{ { - Name: github.Ptr("v1.0.0"), + Name: new("v1.0.0"), Commit: &github.Commit{ - SHA: github.Ptr("v1.0.0-tag-sha"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/abc123"), + SHA: new("v1.0.0-tag-sha"), + URL: new("https://api.github.com/repos/owner/repo/commits/abc123"), }, - ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v1.0.0"), - TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v1.0.0"), + ZipballURL: new("https://github.com/owner/repo/zipball/v1.0.0"), + TarballURL: new("https://github.com/owner/repo/tarball/v1.0.0"), }, { - Name: github.Ptr("v0.9.0"), + Name: new("v0.9.0"), Commit: &github.Commit{ - SHA: github.Ptr("v0.9.0-tag-sha"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/def456"), + SHA: new("v0.9.0-tag-sha"), + URL: new("https://api.github.com/repos/owner/repo/commits/def456"), }, - ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v0.9.0"), - TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v0.9.0"), + ZipballURL: new("https://github.com/owner/repo/zipball/v0.9.0"), + TarballURL: new("https://github.com/owner/repo/tarball/v0.9.0"), }, } @@ -4456,7 +4528,6 @@ func Test_GetTag(t *testing.T) { // Verify tool definition once serverTool := GetTag(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") @@ -4469,28 +4540,28 @@ func Test_GetTag(t *testing.T) { assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "tag"}) mockAnnotatedTagRef := &github.Reference{ - Ref: github.Ptr("refs/tags/v1.0.0"), + Ref: new("refs/tags/v1.0.0"), Object: &github.GitObject{ - Type: github.Ptr("tag"), - SHA: github.Ptr("v1.0.0-tag-sha"), + Type: new("tag"), + SHA: new("v1.0.0-tag-sha"), }, } mockLightweightTagRef := &github.Reference{ - Ref: github.Ptr("refs/tags/v1.0.1"), + Ref: new("refs/tags/v1.0.1"), Object: &github.GitObject{ - Type: github.Ptr("commit"), - SHA: github.Ptr("abc123"), + Type: new("commit"), + SHA: new("abc123"), }, } mockTagObj := &github.Tag{ - SHA: github.Ptr("v1.0.0-tag-sha"), - Tag: github.Ptr("v1.0.0"), - Message: github.Ptr("Release v1.0.0"), + SHA: new("v1.0.0-tag-sha"), + Tag: new("v1.0.0"), + Message: new("Release v1.0.0"), Object: &github.GitObject{ - Type: github.Ptr("commit"), - SHA: github.Ptr("abc123"), + Type: new("commit"), + SHA: new("abc123"), }, } @@ -4658,7 +4729,6 @@ func Test_GetTag(t *testing.T) { func Test_ListReleases(t *testing.T) { serverTool := ListReleases(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") @@ -4674,12 +4744,12 @@ func Test_ListReleases(t *testing.T) { { ID: 1, TagName: "v1.0.0", - Name: github.Ptr("First Release"), + Name: new("First Release"), }, { ID: 2, TagName: "v0.9.0", - Name: github.Ptr("Beta Release"), + Name: new("Beta Release"), }, } @@ -4758,7 +4828,6 @@ func Test_ListReleases(t *testing.T) { func Test_GetLatestRelease(t *testing.T) { serverTool := GetLatestRelease(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") @@ -4772,7 +4841,8 @@ func Test_GetLatestRelease(t *testing.T) { mockRelease := &github.RepositoryRelease{ ID: 1, TagName: "v1.0.0", - Name: github.Ptr("First Release"), + Name: new("can't \"quote\" AT&T\u200B"), + Body: new("
Notes
\u200B"), } tests := []struct { @@ -4840,6 +4910,8 @@ func Test_GetLatestRelease(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) require.NoError(t, err) assert.Equal(t, tc.expectedResult.TagName, returnedRelease.TagName) + assert.Equal(t, "can't \"quote\" AT&T", *returnedRelease.Name) + assert.Equal(t, "
Notes
", *returnedRelease.Body) }) } } @@ -4847,7 +4919,6 @@ func Test_GetLatestRelease(t *testing.T) { func Test_GetReleaseByTag(t *testing.T) { serverTool := GetReleaseByTag(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") @@ -4862,12 +4933,12 @@ func Test_GetReleaseByTag(t *testing.T) { mockRelease := &github.RepositoryRelease{ ID: 1, TagName: "v1.0.0", - Name: github.Ptr("Release v1.0.0"), - Body: github.Ptr("This is the first stable release."), + Name: new("can't \"quote\" AT&T\u200B"), + Body: new("
Notes
\u200B"), Assets: []*github.ReleaseAsset{ { - ID: github.Ptr(int64(1)), - Name: github.Ptr("release-v1.0.0.tar.gz"), + ID: new(int64(1)), + Name: new("release-v1.0.0.tar.gz"), }, }, } @@ -5003,9 +5074,9 @@ func Test_GetReleaseByTag(t *testing.T) { assert.Equal(t, tc.expectedResult.ID, returnedRelease.ID) assert.Equal(t, tc.expectedResult.TagName, returnedRelease.TagName) - assert.Equal(t, *tc.expectedResult.Name, *returnedRelease.Name) + assert.Equal(t, "can't \"quote\" AT&T", *returnedRelease.Name) if tc.expectedResult.Body != nil { - assert.Equal(t, *tc.expectedResult.Body, *returnedRelease.Body) + assert.Equal(t, "
Notes
", *returnedRelease.Body) } if len(tc.expectedResult.Assets) > 0 { require.Len(t, returnedRelease.Assets, len(tc.expectedResult.Assets)) @@ -5030,7 +5101,7 @@ func Test_GetReleaseByTag_IFC_FeatureFlag(t *testing.T) { return &github.RepositoryRelease{ ID: 1, TagName: "v1.0.0", - Name: github.Ptr("v1.0.0"), + Name: new("v1.0.0"), Draft: draft, } } @@ -5193,10 +5264,10 @@ func Test_filterPaths(t *testing.T) { { name: "file name", tree: []*github.TreeEntry{ - {Path: github.Ptr("folder/foo.txt"), Type: github.Ptr("blob")}, - {Path: github.Ptr("bar.txt"), Type: github.Ptr("blob")}, - {Path: github.Ptr("nested/folder/foo.txt"), Type: github.Ptr("blob")}, - {Path: github.Ptr("nested/folder/baz.txt"), Type: github.Ptr("blob")}, + {Path: new("folder/foo.txt"), Type: new("blob")}, + {Path: new("bar.txt"), Type: new("blob")}, + {Path: new("nested/folder/foo.txt"), Type: new("blob")}, + {Path: new("nested/folder/baz.txt"), Type: new("blob")}, }, path: "foo.txt", maxResults: -1, @@ -5205,10 +5276,10 @@ func Test_filterPaths(t *testing.T) { { name: "dir name", tree: []*github.TreeEntry{ - {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("bar.txt"), Type: github.Ptr("blob")}, - {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/folder/baz.txt"), Type: github.Ptr("blob")}, + {Path: new("folder"), Type: new("tree")}, + {Path: new("bar.txt"), Type: new("blob")}, + {Path: new("nested/folder"), Type: new("tree")}, + {Path: new("nested/folder/baz.txt"), Type: new("blob")}, }, path: "folder/", maxResults: -1, @@ -5217,8 +5288,8 @@ func Test_filterPaths(t *testing.T) { { name: "dir and file match", tree: []*github.TreeEntry{ - {Path: github.Ptr("name"), Type: github.Ptr("tree")}, - {Path: github.Ptr("name"), Type: github.Ptr("blob")}, + {Path: new("name"), Type: new("tree")}, + {Path: new("name"), Type: new("blob")}, }, path: "name", // No trailing slash can match both files and directories maxResults: -1, @@ -5227,8 +5298,8 @@ func Test_filterPaths(t *testing.T) { { name: "dir only match", tree: []*github.TreeEntry{ - {Path: github.Ptr("name"), Type: github.Ptr("tree")}, - {Path: github.Ptr("name"), Type: github.Ptr("blob")}, + {Path: new("name"), Type: new("tree")}, + {Path: new("name"), Type: new("blob")}, }, path: "name/", // Trialing slash ensures only directories are matched maxResults: -1, @@ -5237,9 +5308,9 @@ func Test_filterPaths(t *testing.T) { { name: "max results limit 2", tree: []*github.TreeEntry{ - {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, + {Path: new("folder"), Type: new("tree")}, + {Path: new("nested/folder"), Type: new("tree")}, + {Path: new("nested/nested/folder"), Type: new("tree")}, }, path: "folder/", maxResults: 2, @@ -5248,9 +5319,9 @@ func Test_filterPaths(t *testing.T) { { name: "max results limit 1", tree: []*github.TreeEntry{ - {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, + {Path: new("folder"), Type: new("tree")}, + {Path: new("nested/folder"), Type: new("tree")}, + {Path: new("nested/nested/folder"), Type: new("tree")}, }, path: "folder/", maxResults: 1, @@ -5259,9 +5330,9 @@ func Test_filterPaths(t *testing.T) { { name: "max results limit 0", tree: []*github.TreeEntry{ - {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, + {Path: new("folder"), Type: new("tree")}, + {Path: new("nested/folder"), Type: new("tree")}, + {Path: new("nested/nested/folder"), Type: new("tree")}, }, path: "folder/", maxResults: 0, @@ -5542,7 +5613,6 @@ func Test_ListStarredRepositories(t *testing.T) { // Verify tool definition once serverTool := ListStarredRepositories(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") @@ -5563,39 +5633,39 @@ func Test_ListStarredRepositories(t *testing.T) { { StarredAt: &github.Timestamp{Time: starredAt}, Repository: &github.Repository{ - ID: github.Ptr(int64(12345)), - Name: github.Ptr("awesome-repo"), - FullName: github.Ptr("owner/awesome-repo"), - Description: github.Ptr("An awesome repository"), - HTMLURL: github.Ptr("https://github.com/owner/awesome-repo"), - Language: github.Ptr("Go"), - StargazersCount: github.Ptr(100), - ForksCount: github.Ptr(25), - OpenIssuesCount: github.Ptr(5), + ID: new(int64(12345)), + Name: new("awesome-repo"), + FullName: new("owner/awesome-repo"), + Description: new("An awesome repository"), + HTMLURL: new("https://github.com/owner/awesome-repo"), + Language: new("Go"), + StargazersCount: new(100), + ForksCount: new(25), + OpenIssuesCount: new(5), UpdatedAt: &github.Timestamp{Time: updatedAt}, - Private: github.Ptr(false), - Fork: github.Ptr(false), - Archived: github.Ptr(false), - DefaultBranch: github.Ptr("main"), + Private: new(false), + Fork: new(false), + Archived: new(false), + DefaultBranch: new("main"), }, }, { StarredAt: &github.Timestamp{Time: starredAt.Add(-12 * time.Hour)}, Repository: &github.Repository{ - ID: github.Ptr(int64(67890)), - Name: github.Ptr("cool-project"), - FullName: github.Ptr("user/cool-project"), - Description: github.Ptr("A very cool project"), - HTMLURL: github.Ptr("https://github.com/user/cool-project"), - Language: github.Ptr("Python"), - StargazersCount: github.Ptr(500), - ForksCount: github.Ptr(75), - OpenIssuesCount: github.Ptr(10), + ID: new(int64(67890)), + Name: new("cool-project"), + FullName: new("user/cool-project"), + Description: new("A very cool project"), + HTMLURL: new("https://github.com/user/cool-project"), + Language: new("Python"), + StargazersCount: new(500), + ForksCount: new(75), + OpenIssuesCount: new(10), UpdatedAt: &github.Timestamp{Time: updatedAt.Add(-1 * time.Hour)}, - Private: github.Ptr(false), - Fork: github.Ptr(true), - Archived: github.Ptr(false), - DefaultBranch: github.Ptr("master"), + Private: new(false), + Fork: new(true), + Archived: new(false), + DefaultBranch: new("master"), }, }, } @@ -5704,7 +5774,6 @@ func Test_StarRepository(t *testing.T) { // Verify tool definition once serverTool := StarRepository(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") @@ -5795,7 +5864,6 @@ func Test_UnstarRepository(t *testing.T) { // Verify tool definition once serverTool := UnstarRepository(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") @@ -5885,11 +5953,10 @@ func Test_GetFileBlame(t *testing.T) { // Verify tool definition once serverTool := GetFileBlame(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) // get_file_blame is gated so it is not advertised unless the feature flag // (or insiders mode) opts it in. - assert.Equal(t, FeatureFlagFileBlame, serverTool.FeatureFlagEnable, "get_file_blame must be gated behind the file_blame feature flag") + assert.Equal(t, []inventory.FeatureFlag{FeatureFlagFileBlame}, serverTool.FeatureRule.Features()) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -6598,7 +6665,6 @@ 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") @@ -6615,14 +6681,14 @@ func Test_ListRepositoryCollaborators(t *testing.T) { mockCollaborators := []*github.User{ { - Login: github.Ptr("user1"), - ID: github.Ptr(int64(101)), - RoleName: github.Ptr("admin"), + Login: new("user1"), + ID: new(int64(101)), + RoleName: new("admin"), }, { - Login: github.Ptr("user2"), - ID: github.Ptr(int64(102)), - RoleName: github.Ptr("write"), + Login: new("user2"), + ID: new(int64(102)), + RoleName: new("write"), }, } diff --git a/pkg/github/repository_path.go b/pkg/github/repository_path.go index 4d502fe1db..ebc0aa62db 100644 --- a/pkg/github/repository_path.go +++ b/pkg/github/repository_path.go @@ -6,7 +6,7 @@ import ( "slices" "strings" - "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/scopes" ) const workflowPathPrefix = ".github/workflows/" @@ -75,8 +75,12 @@ func workflowScopeChallengeForFiles(arguments map[string]any, activeScopes []str containsWorkflow = true } } - if containsWorkflow { - return scopes.ChallengeAll(activeScopes, scopes.Repo, scopes.Workflow) + var challenge []string + if !scopes.HasAll(activeScopes, scopes.Repo) { + challenge = append(challenge, string(scopes.Repo)) } - return scopes.ChallengeAll(activeScopes, scopes.Repo) + if containsWorkflow && !scopes.HasAll(activeScopes, scopes.Workflow) { + challenge = append(challenge, string(scopes.Workflow)) + } + return challenge } diff --git a/pkg/github/repository_path_test.go b/pkg/github/repository_path_test.go index d9178ae2af..b23fe86ce5 100644 --- a/pkg/github/repository_path_test.go +++ b/pkg/github/repository_path_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -84,7 +84,7 @@ func TestFileWriteWorkflowScopeChallenges(t *testing.T) { map[string]any{"path": "README.md"}, map[string]any{"path": ".github/workflows/ci.yml"}, }}, - want: []string{"repo", "workflow"}, + want: []string{"workflow"}, }, { name: "push validates entries after workflow", diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index 323cdf45d2..f7e8410146 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -11,11 +11,11 @@ import ( "strconv" "strings" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/octicons" - "github.com/github/github-mcp-server/pkg/raw" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/octicons" + "github.com/github/github-mcp-server/v2/pkg/raw" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/yosida95/uritemplate/v3" ) diff --git a/pkg/github/repository_resource_completions.go b/pkg/github/repository_resource_completions.go index 2c05d0531e..deb4b738dc 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/v89/github" + "github.com/google/go-github/v92/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 22c083b466..7d20d1e1db 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/v89/github" + "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go index c1402da60d..900842b9bb 100644 --- a/pkg/github/repository_resource_test.go +++ b/pkg/github/repository_resource_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/v2/pkg/raw" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/repository_tool_types.go b/pkg/github/repository_tool_types.go new file mode 100644 index 0000000000..4f3b584983 --- /dev/null +++ b/pkg/github/repository_tool_types.go @@ -0,0 +1,542 @@ +package github + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "maps" + + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type RepositoryInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` +} + +type CreateOrUpdateFileInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Path string `json:"path"` + Content string `json:"content"` + Message string `json:"message"` + Branch string `json:"branch"` + SHA string `json:"sha,omitempty"` + AllowSymlinkWrite bool `json:"allow_symlink_write,omitempty"` +} + +type CreateRepositoryInput struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Organization string `json:"organization,omitempty"` + Private *bool `json:"private,omitempty"` + AutoInit bool `json:"autoInit,omitempty"` +} + +type GetFileContentsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Path string `json:"path,omitempty"` + Ref string `json:"ref,omitempty"` + SHA string `json:"sha,omitempty"` + Fields []string `json:"fields,omitempty"` +} + +type ForkRepositoryInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Organization string `json:"organization,omitempty"` +} + +type DeleteFileInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Path string `json:"path"` + Message string `json:"message"` + Branch string `json:"branch"` +} + +type CreateBranchInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Branch string `json:"branch"` + FromBranch string `json:"from_branch,omitempty"` +} + +type PushFileInput struct { + Path string `json:"path"` + Content *string `json:"content"` +} + +type PushFilesInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Branch string `json:"branch"` + Message string `json:"message"` + Files []PushFileInput `json:"files"` +} + +type RepositoryTagInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Tag string `json:"tag"` +} + +type ListReleasesInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Fields []string `json:"fields,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type ListStarredRepositoriesInput struct { + Username string `json:"username,omitempty"` + Sort string `json:"sort,omitempty"` + Direction string `json:"direction,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type GetFileBlameInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Path string `json:"path"` + Ref string `json:"ref,omitempty"` + StartLine *int `json:"start_line,omitempty"` + EndLine *int `json:"end_line,omitempty"` + PerPage *int `json:"perPage,omitempty"` + After string `json:"after,omitempty"` +} + +type ListRepositoryCollaboratorsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Affiliation string `json:"affiliation,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type RepositoryMessageOutput struct { + Message string `json:"message"` +} + +type RepositoryCollaboratorsOutput struct { + FirstPage int `json:"firstPage"` + Items []MinimalCollaborator `json:"items"` + LastPage int `json:"lastPage"` + NextPage int `json:"nextPage"` + PrevPage int `json:"prevPage"` +} + +type RepositoryBlameOutput struct { + Result *BlameResult +} + +func (out RepositoryBlameOutput) MarshalJSON() ([]byte, error) { + return json.Marshal(out.Result) +} + +// Pointer fields preserve both a complete compact release and a requested +// projection, including explicit false/zero values. +type ReleaseListOutput struct { + ID *int64 `json:"id,omitempty"` + TagName *string `json:"tag_name,omitempty"` + Name *string `json:"name,omitempty"` + Body *string `json:"body,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + PublishedAt *string `json:"published_at,omitempty" jsonschema:"Publication time in RFC3339 format."` + Prerelease *bool `json:"prerelease,omitempty" jsonschema:"Whether the release is a prerelease."` + Draft *bool `json:"draft,omitempty" jsonschema:"Whether the release is unpublished."` + Author *MinimalUser `json:"author,omitempty"` +} + +type RepositoryGitObjectOutput struct { + Type string `json:"type" jsonschema:"Git object kind: commit, tag, tree, or blob."` + SHA string `json:"sha"` +} + +type RepositoryReferenceOutput struct { + Ref string `json:"ref" jsonschema:"Fully qualified reference, such as refs/heads/main."` + Object RepositoryGitObjectOutput `json:"object"` +} + +type RepositoryAnnotatedTagOutput struct { + Tag string `json:"tag"` + SHA string `json:"sha"` + Message string `json:"message,omitempty"` + Tagger *MinimalCommitAuthor `json:"tagger,omitempty"` + Object *RepositoryGitObjectOutput `json:"object,omitempty"` +} + +type RepositoryGitCommitOutput struct { + SHA string `json:"sha"` + Message string `json:"message,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + Author *MinimalCommitAuthor `json:"author,omitempty"` + Committer *MinimalCommitAuthor `json:"committer,omitempty"` + TreeSHA string `json:"tree_sha,omitempty"` + Parents []string `json:"parents,omitempty" jsonschema:"Parent commit SHAs in Git parent order."` +} + +type DeleteFileOutput struct { + Commit *RepositoryGitCommitOutput `json:"commit"` + Content *struct{} `json:"content"` +} + +type RepositoryTagOutput struct { + Reference *RepositoryReferenceOutput + Tag *RepositoryAnnotatedTagOutput +} + +func (out RepositoryTagOutput) MarshalJSON() ([]byte, error) { + if out.Reference != nil { + return json.Marshal(out.Reference) + } + return json.Marshal(out.Tag) +} + +type ForkRepositoryOutput struct { + Repository *MinimalResponse + Message *RepositoryMessageOutput +} + +func (out ForkRepositoryOutput) MarshalJSON() ([]byte, error) { + if out.Repository != nil { + return json.Marshal(out.Repository) + } + return json.Marshal(out.Message) +} + +// Repository contents can be a projected directory listing or a heterogeneous +// MCP content response. The latter is represented losslessly as typed content +// blocks, including the human-readable status and embedded text/blob or link. +type RepositoryContentsOutput struct { + Directory []*RepositoryDirectoryEntryOutput + Content *RepositoryContentOutput +} + +// Optional pointer fields retain projected zero values without reinstating +// omitted fields. API URLs are included only when explicitly projected. +type RepositoryDirectoryEntryOutput struct { + Type *string `json:"type,omitempty"` + Name *string `json:"name,omitempty"` + Path *string `json:"path,omitempty"` + SHA *string `json:"sha,omitempty"` + Size *int `json:"size,omitempty" jsonschema:"File size in bytes."` + HTMLURL *string `json:"html_url,omitempty"` + DownloadURL *string `json:"download_url,omitempty"` + URL *string `json:"url,omitempty"` + GitURL *string `json:"git_url,omitempty"` + Target *string `json:"target,omitempty"` + SubmoduleGitURL *string `json:"submodule_git_url,omitempty" jsonschema:"Git remote URL for a submodule."` +} + +func repositoryReferenceOutput(ref *github.Reference) *RepositoryReferenceOutput { + if ref == nil { + return nil + } + return &RepositoryReferenceOutput{ + Ref: ref.GetRef(), + Object: RepositoryGitObjectOutput{ + Type: ref.GetObject().GetType(), SHA: ref.GetObject().GetSHA(), + }, + } +} + +func repositoryCommitAuthorOutput(author *github.CommitAuthor) *MinimalCommitAuthor { + if author == nil { + return nil + } + out := &MinimalCommitAuthor{Name: author.GetName(), Email: author.GetEmail()} + if author.Date != nil { + out.Date = author.Date.Format("2006-01-02T15:04:05Z07:00") + } + return out +} + +func repositoryGitCommitOutput(commit *github.Commit) *RepositoryGitCommitOutput { + if commit == nil { + return nil + } + out := &RepositoryGitCommitOutput{ + SHA: commit.GetSHA(), Message: commit.GetMessage(), HTMLURL: commit.GetHTMLURL(), + Author: repositoryCommitAuthorOutput(commit.Author), Committer: repositoryCommitAuthorOutput(commit.Committer), + TreeSHA: commit.GetTree().GetSHA(), + } + for _, parent := range commit.Parents { + if parent != nil { + out.Parents = append(out.Parents, parent.GetSHA()) + } + } + return out +} + +func repositoryAnnotatedTagOutput(tag *github.Tag) *RepositoryAnnotatedTagOutput { + if tag == nil { + return nil + } + out := &RepositoryAnnotatedTagOutput{ + Tag: tag.GetTag(), SHA: tag.GetSHA(), Message: tag.GetMessage(), + Tagger: repositoryCommitAuthorOutput(tag.Tagger), + } + if tag.Object != nil { + out.Object = &RepositoryGitObjectOutput{Type: tag.Object.GetType(), SHA: tag.Object.GetSHA()} + } + return out +} + +func (out RepositoryContentsOutput) MarshalJSON() ([]byte, error) { + if out.Content != nil { + return json.Marshal(out.Content) + } + return json.Marshal(out.Directory) +} + +type RepositoryContentOutput struct { + Content []RepositoryContentBlock `json:"content"` +} + +type RepositoryContentBlock struct { + Text *RepositoryTextBlock + Resource *RepositoryResourceBlock + Link *RepositoryLinkBlock +} + +func (out RepositoryContentBlock) MarshalJSON() ([]byte, error) { + switch { + case out.Text != nil: + return json.Marshal(out.Text) + case out.Resource != nil: + return json.Marshal(out.Resource) + default: + return json.Marshal(out.Link) + } +} + +type RepositoryTextBlock struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type RepositoryResourceBlock struct { + Type string `json:"type"` + Resource RepositoryResourceOutput `json:"resource"` +} + +type RepositoryResourceOutput struct { + URI string `json:"uri"` + MIMEType string `json:"mimeType,omitempty"` + Text *string `json:"text,omitempty"` + Blob *string `json:"blob,omitempty" jsonschema:"Base64-encoded file bytes."` +} + +type RepositoryLinkBlock struct { + Type string `json:"type"` + URI string `json:"uri"` + Name string `json:"name"` + Title string `json:"title,omitempty"` + Size *int64 `json:"size,omitempty" jsonschema:"File size in bytes."` +} + +func repositoryContentOutput(result *mcp.CallToolResult) (*RepositoryContentsOutput, error) { + if result == nil || result.IsError { + return nil, nil + } + out := &RepositoryContentOutput{Content: make([]RepositoryContentBlock, 0, len(result.Content))} + for _, content := range result.Content { + switch block := content.(type) { + case *mcp.TextContent: + out.Content = append(out.Content, RepositoryContentBlock{Text: &RepositoryTextBlock{Type: "text", Text: block.Text}}) + case *mcp.EmbeddedResource: + resource := RepositoryResourceOutput{URI: block.Resource.URI, MIMEType: block.Resource.MIMEType} + if block.Resource.Blob != nil { + blob := base64.StdEncoding.EncodeToString(block.Resource.Blob) + resource.Blob = &blob + } else if block.Resource.Text != "" { + resource.Text = &block.Resource.Text + } + out.Content = append(out.Content, RepositoryContentBlock{Resource: &RepositoryResourceBlock{Type: "resource", Resource: resource}}) + case *mcp.ResourceLink: + out.Content = append(out.Content, RepositoryContentBlock{Link: &RepositoryLinkBlock{ + Type: "resource_link", URI: block.URI, Name: block.Name, Title: block.Title, Size: block.Size, + }}) + default: + return nil, fmt.Errorf("unsupported repository content block %T", content) + } + } + return &RepositoryContentsOutput{Content: out}, nil +} + +func repositoryContentResult(result *mcp.CallToolResult) (*mcp.CallToolResult, *RepositoryContentsOutput, error) { + out, err := repositoryContentOutput(result) + return result, out, err +} + +func normalizeRepositoryArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + for _, field := range []string{"page", "perPage"} { + if value, present := args[field]; present { + number, err := toInt(value) + if err != nil { + return nil, fmt.Errorf("parameter %s is not a valid number: %w", field, err) + } + if number == 0 { + delete(args, field) + } else { + args[field] = number + } + } + } + return json.Marshal(args) +} + +func normalizeRepositoryFieldsArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + if fields, present := args["fields"]; present && fields == nil { + delete(args, "fields") + } + if _, err := OptionalStringArrayParam(args, "fields"); err != nil { + return nil, err + } + return json.Marshal(args) +} + +func normalizeRepositoryBlameArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + if _, present := args["page"]; present { + return nil, fmt.Errorf("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'.") //nolint:revive,staticcheck // Preserve the legacy validation message. + } + for _, field := range []string{"start_line", "end_line", "perPage"} { + if value, present := args[field]; present { + number, err := toInt(value) + if err != nil { + return nil, fmt.Errorf("parameter %s is not a valid number: %w", field, err) + } + args[field] = number + } + } + return json.Marshal(args) +} + +func normalizePushFilesArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + files, ok := args["files"].([]any) + if !ok { + return nil, fmt.Errorf("files parameter must be an array of objects with path and content") + } + for _, file := range files { + object, ok := file.(map[string]any) + if !ok { + return nil, fmt.Errorf("each file must be an object with path and content") + } + path, ok := object["path"].(string) + if !ok || path == "" { + return nil, fmt.Errorf("each file must have a path") + } + if _, ok := object["content"].(string); !ok { + return nil, fmt.Errorf("each file must have content") + } + } + return raw, nil +} + +func repositoryPagination(page, perPage *int) PaginationParams { + out := PaginationParams{Page: 1, PerPage: 30} + if page != nil && *page != 0 { + out.Page = *page + } + if perPage != nil && *perPage != 0 { + out.PerPage = *perPage + } + return out +} + +func repositoryOutputSchema[T any]() *jsonschema.Schema { + schema, err := inventory.CachedSchemaFor[T](nil) + if err != nil { + panic(fmt.Sprintf("failed to generate repository output schema: %v", err)) + } + return inventory.CloneSchema(schema) +} + +func repositoryUnionSchema(variants ...*jsonschema.Schema) *jsonschema.Schema { + out := &jsonschema.Schema{OneOf: variants, Defs: make(map[string]*jsonschema.Schema)} + for _, variant := range variants { + maps.Copy(out.Defs, variant.Defs) + variant.Defs = nil + } + return out +} + +func repositoryTagOutputSchema() *jsonschema.Schema { + ref := repositoryOutputSchema[RepositoryReferenceOutput]() + tag := repositoryOutputSchema[RepositoryAnnotatedTagOutput]() + tag.Properties["tagger"].Properties["date"].Description = "Tagger time in RFC3339 format." + return repositoryUnionSchema(&jsonschema.Schema{Type: "null"}, ref, tag) +} + +func repositoryReleaseOutputSchema() *jsonschema.Schema { + schema := repositoryOutputSchema[*MinimalRelease]() + schema.Properties["published_at"].Description = "Publication time in RFC3339 format." + schema.Properties["prerelease"].Description = "Whether the release is a prerelease." + schema.Properties["draft"].Description = "Whether the release is unpublished." + return schema +} + +func forkRepositoryOutputSchema() *jsonschema.Schema { + return repositoryUnionSchema( + &jsonschema.Schema{Type: "null"}, + repositoryOutputSchema[MinimalResponse](), + repositoryOutputSchema[RepositoryMessageOutput](), + ) +} + +func deleteFileOutputSchema() *jsonschema.Schema { + schema := repositoryOutputSchema[*DeleteFileOutput]() + schema.Properties["content"] = &jsonschema.Schema{Type: "null"} + for _, field := range []string{"author", "committer"} { + schema.Properties["commit"].Properties[field].Properties["date"].Description = "Commit timestamp in RFC3339 format." + } + return schema +} + +func repositoryContentsOutputSchema() *jsonschema.Schema { + text := repositoryOutputSchema[RepositoryTextBlock]() + text.Properties["type"].Const = new(any("text")) + resource := repositoryOutputSchema[RepositoryResourceBlock]() + resource.Properties["type"].Const = new(any("resource")) + resource.Properties["resource"].OneOf = []*jsonschema.Schema{ + {Required: []string{"text"}, Not: &jsonschema.Schema{Required: []string{"blob"}}}, + {Required: []string{"blob"}, Not: &jsonschema.Schema{Required: []string{"text"}}}, + {Not: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{{Required: []string{"text"}}, {Required: []string{"blob"}}}}}, + } + link := repositoryOutputSchema[RepositoryLinkBlock]() + link.Properties["type"].Const = new(any("resource_link")) + content := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "content": {Type: "array", Items: repositoryUnionSchema(text, resource, link)}, + }, + Required: []string{"content"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + } + directory := repositoryOutputSchema[[]*RepositoryDirectoryEntryOutput]() + directory.Type, directory.Types = "array", nil + return repositoryUnionSchema(&jsonschema.Schema{Type: "null"}, directory, content) +} diff --git a/pkg/github/resources.go b/pkg/github/resources.go index 2db7cac551..6eb88fa9a7 100644 --- a/pkg/github/resources.go +++ b/pkg/github/resources.go @@ -1,8 +1,8 @@ package github import ( - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" ) // AllResources returns all resource templates with their embedded toolset metadata. diff --git a/pkg/github/rulesets.go b/pkg/github/rulesets.go new file mode 100644 index 0000000000..af0ce1b3c2 --- /dev/null +++ b/pkg/github/rulesets.go @@ -0,0 +1,1073 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// rulesetLevelDescription documents the "level" parameter shared by the +// ruleset read and write tools. +const rulesetLevelDescription = "The level at which the ruleset is configured:\n" + + "- 'repository': A ruleset on a single repository (requires 'owner' and 'repo').\n" + + "- 'organization': A ruleset covering repositories in an organization (requires 'org').\n" + + "- 'enterprise': A ruleset covering repositories across an enterprise (requires 'enterprise')." + +// rulesetReadScopeAccess declares the exhaustive scope challenge policy for +// repository_ruleset_read. The exact scope challenged depends on the "level" +// argument: repository reads need "repo", organization reads need "read:org", +// and enterprise reads need "read:enterprise". A missing or unrecognized +// level returns no challenge so normal handler validation produces the error. +func rulesetReadScopeAccess() inventory.ScopeAccess { + return scopes.DynamicChallenge( + []scopes.Scope{scopes.Repo, scopes.ReadOrg, scopes.ReadEnterprise}, + func([]string) bool { + // Repository-level reads may target public repositories, so the + // tool stays visible even for tokens without any of these scopes. + return true + }, + func(arguments map[string]any, activeScopes []string) []string { + level, ok := arguments["level"].(string) + if !ok { + return nil + } + switch level { + case "repository": + return scopes.ChallengeAll(activeScopes, scopes.Repo) + case "organization": + return scopes.ChallengeAll(activeScopes, scopes.ReadOrg) + case "enterprise": + return scopes.ChallengeAll(activeScopes, scopes.ReadEnterprise) + default: + return nil + } + }, + ) +} + +// rulesetWriteScopeAccess declares the exhaustive scope challenge policy for +// create_repository_ruleset. The exact scope challenged depends on the +// "level" argument: repository writes need "repo", organization writes need +// "admin:org", and enterprise writes need "admin:enterprise". A missing or +// unrecognized level returns no challenge so normal handler validation +// produces the error. +func rulesetWriteScopeAccess() inventory.ScopeAccess { + return scopes.DynamicChallenge( + []scopes.Scope{scopes.Repo, scopes.AdminOrg, scopes.AdminEnterprise}, + func(activeScopes []string) bool { + return scopes.HasAll(activeScopes, scopes.Repo) || + scopes.HasAll(activeScopes, scopes.AdminOrg) || + scopes.HasAll(activeScopes, scopes.AdminEnterprise) + }, + func(arguments map[string]any, activeScopes []string) []string { + level, ok := arguments["level"].(string) + if !ok { + return nil + } + switch level { + case "repository": + return scopes.ChallengeAll(activeScopes, scopes.Repo) + case "organization": + return scopes.ChallengeAll(activeScopes, scopes.AdminOrg) + case "enterprise": + return scopes.ChallengeAll(activeScopes, scopes.AdminEnterprise) + default: + return nil + } + }, + ) +} + +// RepositoryRulesetRead creates a tool for ruleset and rule-suite reads. +func RepositoryRulesetRead(t translations.TranslationHelperFunc) inventory.ServerTool { + return typedGovernanceTool(repositoryRulesetReadLegacy(t), rulesetReadOutputSchema(), decodeRulesetRead, normalizeGovernanceLevelRouting) +} + +func repositoryRulesetReadLegacy(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "repository_ruleset_read", + Description: t("TOOL_REPOSITORY_RULESET_READ_DESCRIPTION", "Read rulesets at the repository, organization, or enterprise level, and rule suites at the repository or organization level. Select the level with the 'level' parameter and the operation with the 'method' parameter."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_REPOSITORY_RULESET_READ_USER_TITLE", "Read repository rulesets"), + ReadOnlyHint: true, + }, + InputSchema: WithPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "level": { + Type: "string", + Enum: []any{"repository", "organization", "enterprise"}, + Description: rulesetLevelDescription, + }, + "method": { + Type: "string", + Enum: []any{"get", "list", "get_rules_for_branch", "list_rule_suites", "get_rule_suite"}, + Description: "Operation to perform:\n" + + "- 'get': Get a specific ruleset by ID (requires 'ruleset_id'). Supported at every level.\n" + + "- 'list': List all rulesets. Supported at every level.\n" + + "- 'get_rules_for_branch': Get all rules that apply to a branch (requires 'branch'). Repository level only.\n" + + "- 'list_rule_suites': List rule suites, the evaluations of rules against pushes. Repository and organization levels only.\n" + + "- 'get_rule_suite': Get a specific rule suite by ID (requires 'rule_suite_id'). Repository and organization levels only.", + }, + "owner": { + Type: "string", + Description: "Repository owner. Required when level is 'repository'.", + }, + "repo": { + Type: "string", + Description: "Repository name. Required when level is 'repository'.", + }, + "org": { + Type: "string", + Description: "Organization name. Required when level is 'organization'.", + }, + "enterprise": { + Type: "string", + Description: "Enterprise slug. Required when level is 'enterprise'.", + }, + "ruleset_id": { + Type: "number", + Description: "Ruleset ID. Required for the 'get' method.", + }, + "includes_parents": { + Type: "boolean", + Description: "Include rulesets configured at higher levels that also apply. Defaults to true. Used by the 'get' and 'list' methods at the repository level.", + }, + "branch": { + Type: "string", + Description: "Branch name. Required for the 'get_rules_for_branch' method.", + }, + "ref": { + Type: "string", + Description: "The name of the ref (branch, tag, etc.) to filter rule suites by. Used by the 'list_rule_suites' method.", + }, + "time_period": { + Type: "string", + Enum: []any{"hour", "day", "week", "month"}, + Description: "The time period to filter rule suites by. Used by the 'list_rule_suites' method.", + }, + "actor_name": { + Type: "string", + Description: "The handle for the GitHub user account to filter rule suites on. Used by the 'list_rule_suites' method.", + }, + "repository_name": { + Type: "string", + Description: "Repository name to filter rule suites by. Used by the 'list_rule_suites' method at the organization level.", + }, + "rule_suite_result": { + Type: "string", + Enum: []any{"pass", "fail", "bypass", "all"}, + Description: "The rule suite result to filter by. Used by the 'list_rule_suites' method.", + }, + "evaluate_status": { + Type: "string", + Enum: []any{"all", "active", "evaluate"}, + Description: "Filter rule suites by ruleset evaluation mode. Used by the 'list_rule_suites' method.", + }, + "rule_suite_id": { + Type: "number", + Description: "Rule suite ID. Required for the 'get_rule_suite' method.", + }, + }, + Required: []string{"level", "method"}, + }), + }, + rulesetReadScopeAccess(), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + level, err := RequiredParam[string](args, "level") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + method, err := RequiredParam[string](args, "method") + 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) + } + + switch level { + case "repository": + return repositoryRulesetReadRepository(ctx, client, strings.ToLower(method), args) + case "organization": + return repositoryRulesetReadOrganization(ctx, client, strings.ToLower(method), args) + case "enterprise": + return repositoryRulesetReadEnterprise(ctx, client, strings.ToLower(method), args) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown level: %q (expected 'repository', 'organization', or 'enterprise')", level)), nil, nil + } + }, + ) +} + +// repositoryRulesetReadRepository handles repository_ruleset_read calls with level="repository". +func repositoryRulesetReadRepository(ctx context.Context, client *github.Client, method string, 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 + } + + switch method { + case "get": + rulesetID, err := RequiredBigInt(args, "ruleset_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + // GetRuleset always sends includes_parents, so preserve GitHub's true default. + includesParents := true + if _, ok := args["includes_parents"]; ok { + includesParents, err = OptionalParam[bool](args, "includes_parents") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } + result, err := GetRepositoryRuleset(ctx, client, owner, repo, rulesetID, includesParents) + return result, nil, err + case "list": + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + var includesParents *bool + if _, ok := args["includes_parents"]; ok { + v, err := OptionalParam[bool](args, "includes_parents") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + includesParents = &v + } + result, err := ListRepositoryRulesets(ctx, client, owner, repo, includesParents, pagination) + return result, nil, err + case "get_rules_for_branch": + branch, err := RequiredParam[string](args, "branch") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := GetRepositoryRulesForBranch(ctx, client, owner, repo, branch, pagination) + return result, nil, err + case "list_rule_suites": + filters, err := ruleSuiteFiltersFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := ListRepositoryRuleSuites(ctx, client, owner, repo, filters, pagination) + return result, nil, err + case "get_rule_suite": + ruleSuiteID, err := RequiredBigInt(args, "rule_suite_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := GetRepositoryRuleSuite(ctx, client, owner, repo, ruleSuiteID) + return result, nil, err + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %q", method)), nil, nil + } +} + +// repositoryRulesetReadOrganization handles repository_ruleset_read calls with level="organization". +func repositoryRulesetReadOrganization(ctx context.Context, client *github.Client, method string, args map[string]any) (*mcp.CallToolResult, any, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case "get": + rulesetID, err := RequiredBigInt(args, "ruleset_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := GetOrganizationRepositoryRuleset(ctx, client, org, rulesetID) + return result, nil, err + case "list": + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := ListOrganizationRepositoryRulesets(ctx, client, org, pagination) + return result, nil, err + case "list_rule_suites": + filters, err := ruleSuiteFiltersFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := ListOrganizationRuleSuites(ctx, client, org, filters, pagination) + return result, nil, err + case "get_rule_suite": + ruleSuiteID, err := RequiredBigInt(args, "rule_suite_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := GetOrganizationRuleSuite(ctx, client, org, ruleSuiteID) + return result, nil, err + default: + return utils.NewToolResultError(fmt.Sprintf("method %q is not supported for level \"organization\"; supported methods: get, list, list_rule_suites, get_rule_suite", method)), nil, nil + } +} + +// repositoryRulesetReadEnterprise handles repository_ruleset_read calls with level="enterprise". +func repositoryRulesetReadEnterprise(ctx context.Context, client *github.Client, method string, args map[string]any) (*mcp.CallToolResult, any, error) { + enterprise, err := RequiredParam[string](args, "enterprise") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case "get": + rulesetID, err := RequiredBigInt(args, "ruleset_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := GetEnterpriseRepositoryRuleset(ctx, client, enterprise, rulesetID) + return result, nil, err + case "list": + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := ListEnterpriseRepositoryRulesets(ctx, client, enterprise, pagination) + return result, nil, err + default: + return utils.NewToolResultError(fmt.Sprintf("method %q is not supported for level \"enterprise\"; supported methods: get, list", method)), nil, nil + } +} + +// GetRepositoryRuleset gets a specific repository ruleset by ID. +func GetRepositoryRuleset(ctx context.Context, client *github.Client, owner, repo string, rulesetID int64, includesParents bool) (*mcp.CallToolResult, error) { + ruleset, resp, err := client.Repositories.GetRuleset(ctx, owner, repo, rulesetID, includesParents) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get repository ruleset", resp, err), nil + } + + return MarshalledTextResult(ruleset), nil +} + +// ListRepositoryRulesets lists all rulesets for a repository. When +// includesParents is nil GitHub's default behaviour (include parents) is used. +func ListRepositoryRulesets(ctx context.Context, client *github.Client, owner, repo string, includesParents *bool, pagination PaginationParams) (*mcp.CallToolResult, error) { + opts := &github.RepositoryListRulesetsOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + IncludesParents: includesParents, + } + + rulesets, resp, err := client.Repositories.GetAllRulesets(ctx, owner, repo, opts) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list repository rulesets", resp, err), nil + } + + return MarshalledTextResult(rulesets), nil +} + +// GetRepositoryRulesForBranch gets all rules that apply to a specific branch. +func GetRepositoryRulesForBranch(ctx context.Context, client *github.Client, owner, repo, branch string, pagination PaginationParams) (*mcp.CallToolResult, error) { + opts := &github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + } + + branchRules, resp, err := client.Repositories.ListRulesForBranch(ctx, owner, repo, branch, opts) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get repository rules for branch", resp, err), nil + } + + return MarshalledTextResult(branchRules), nil +} + +// RuleSuiteFilters holds the optional filters for listing rule suites. +type RuleSuiteFilters struct { + Ref string + RepositoryName string + TimePeriod string + ActorName string + RuleSuiteResult string + EvaluateStatus string +} + +func ruleSuiteFiltersFromArgs(args map[string]any) (RuleSuiteFilters, error) { + ref, err := OptionalParam[string](args, "ref") + if err != nil { + return RuleSuiteFilters{}, err + } + repositoryName, err := OptionalParam[string](args, "repository_name") + if err != nil { + return RuleSuiteFilters{}, err + } + timePeriod, err := OptionalParam[string](args, "time_period") + if err != nil { + return RuleSuiteFilters{}, err + } + actorName, err := OptionalParam[string](args, "actor_name") + if err != nil { + return RuleSuiteFilters{}, err + } + ruleSuiteResult, err := OptionalParam[string](args, "rule_suite_result") + if err != nil { + return RuleSuiteFilters{}, err + } + evaluateStatus, err := OptionalParam[string](args, "evaluate_status") + if err != nil { + return RuleSuiteFilters{}, err + } + return RuleSuiteFilters{ + Ref: ref, + RepositoryName: repositoryName, + TimePeriod: timePeriod, + ActorName: actorName, + RuleSuiteResult: ruleSuiteResult, + EvaluateStatus: evaluateStatus, + }, nil +} + +// ListRepositoryRuleSuites lists rule suites (evaluations of rules against +// pushes) for a repository. Rule suites are not supported by go-github, so the +// request is issued directly. +func ListRepositoryRuleSuites(ctx context.Context, client *github.Client, owner, repo string, filters RuleSuiteFilters, pagination PaginationParams) (*mcp.CallToolResult, error) { + apiURL := fmt.Sprintf("repos/%s/%s/rulesets/rule-suites", url.PathEscape(owner), url.PathEscape(repo)) + return listRuleSuites(ctx, client, apiURL, "failed to list repository rule suites", filters, false, pagination) +} + +// ListOrganizationRuleSuites lists rule suites for an organization. +func ListOrganizationRuleSuites(ctx context.Context, client *github.Client, org string, filters RuleSuiteFilters, pagination PaginationParams) (*mcp.CallToolResult, error) { + apiURL := fmt.Sprintf("orgs/%s/rulesets/rule-suites", url.PathEscape(org)) + return listRuleSuites(ctx, client, apiURL, "failed to list organization rule suites", filters, true, pagination) +} + +func listRuleSuites(ctx context.Context, client *github.Client, apiURL, errorMessage string, filters RuleSuiteFilters, includeRepositoryName bool, pagination PaginationParams) (*mcp.CallToolResult, error) { + query := url.Values{} + if filters.Ref != "" { + query.Set("ref", filters.Ref) + } + if includeRepositoryName && filters.RepositoryName != "" { + query.Set("repository_name", filters.RepositoryName) + } + if filters.TimePeriod != "" { + query.Set("time_period", filters.TimePeriod) + } + if filters.ActorName != "" { + query.Set("actor_name", filters.ActorName) + } + if filters.RuleSuiteResult != "" { + query.Set("rule_suite_result", filters.RuleSuiteResult) + } + if filters.EvaluateStatus != "" { + query.Set("evaluate_status", filters.EvaluateStatus) + } + if pagination.Page > 0 { + query.Set("page", strconv.Itoa(pagination.Page)) + } + if pagination.PerPage > 0 { + query.Set("per_page", strconv.Itoa(pagination.PerPage)) + } + if len(query) > 0 { + apiURL += "?" + query.Encode() + } + + req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil + } + + var ruleSuites any + resp, err := client.Do(req, &ruleSuites) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, errorMessage, resp, err), nil + } + + return MarshalledTextResult(ruleSuites), nil +} + +// GetRepositoryRuleSuite gets details of a specific repository rule suite. +func GetRepositoryRuleSuite(ctx context.Context, client *github.Client, owner, repo string, ruleSuiteID int64) (*mcp.CallToolResult, error) { + apiURL := fmt.Sprintf("repos/%s/%s/rulesets/rule-suites/%d", url.PathEscape(owner), url.PathEscape(repo), ruleSuiteID) + return getRuleSuite(ctx, client, apiURL, "failed to get repository rule suite") +} + +// GetOrganizationRuleSuite gets details of a specific organization rule suite. +func GetOrganizationRuleSuite(ctx context.Context, client *github.Client, org string, ruleSuiteID int64) (*mcp.CallToolResult, error) { + apiURL := fmt.Sprintf("orgs/%s/rulesets/rule-suites/%d", url.PathEscape(org), ruleSuiteID) + return getRuleSuite(ctx, client, apiURL, "failed to get organization rule suite") +} + +func getRuleSuite(ctx context.Context, client *github.Client, apiURL, errorMessage string) (*mcp.CallToolResult, error) { + req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil + } + + var ruleSuite any + resp, err := client.Do(req, &ruleSuite) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, errorMessage, resp, err), nil + } + + return MarshalledTextResult(ruleSuite), nil +} + +// GetOrganizationRepositoryRuleset gets a specific organization repository +// ruleset by ID. +func GetOrganizationRepositoryRuleset(ctx context.Context, client *github.Client, org string, rulesetID int64) (*mcp.CallToolResult, error) { + ruleset, resp, err := client.Organizations.GetRepositoryRuleset(ctx, org, rulesetID) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get organization repository ruleset", resp, err), nil + } + + return MarshalledTextResult(ruleset), nil +} + +// ListOrganizationRepositoryRulesets lists all repository rulesets for an +// organization. +func ListOrganizationRepositoryRulesets(ctx context.Context, client *github.Client, org string, pagination PaginationParams) (*mcp.CallToolResult, error) { + opts := &github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + } + + rulesets, resp, err := client.Organizations.ListAllRepositoryRulesets(ctx, org, opts) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list organization repository rulesets", resp, err), nil + } + + return MarshalledTextResult(rulesets), nil +} + +// GetEnterpriseRepositoryRuleset gets a specific enterprise repository +// ruleset by ID. +func GetEnterpriseRepositoryRuleset(ctx context.Context, client *github.Client, enterprise string, rulesetID int64) (*mcp.CallToolResult, error) { + ruleset, resp, err := client.Enterprise.GetRepositoryRuleset(ctx, enterprise, rulesetID) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get enterprise repository ruleset", resp, err), nil + } + + return MarshalledTextResult(ruleset), nil +} + +type enterpriseRepositoryRulesetsResponse struct { + TotalCount int `json:"total_count"` + Rulesets []*github.RepositoryRuleset `json:"rulesets"` +} + +// ListEnterpriseRepositoryRulesets lists all repository rulesets for an +// enterprise. Listing enterprise rulesets is not supported by go-github, so +// the request is issued directly. +func ListEnterpriseRepositoryRulesets(ctx context.Context, client *github.Client, enterprise string, pagination PaginationParams) (*mcp.CallToolResult, error) { + apiURL := fmt.Sprintf("enterprises/%s/rulesets", url.PathEscape(enterprise)) + query := url.Values{} + if pagination.Page > 0 { + query.Set("page", strconv.Itoa(pagination.Page)) + } + if pagination.PerPage > 0 { + query.Set("per_page", strconv.Itoa(pagination.PerPage)) + } + if len(query) > 0 { + apiURL += "?" + query.Encode() + } + + req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil + } + + var result enterpriseRepositoryRulesetsResponse + resp, err := client.Do(req, &result) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list enterprise repository rulesets", resp, err), nil + } + + return MarshalledTextResult(result), nil +} + +// CreateRepositoryRuleset creates a tool to create a new repository ruleset +// at the repository, organization, or enterprise level. The level is +// selected with the "level" parameter. +func CreateRepositoryRuleset(t translations.TranslationHelperFunc) inventory.ServerTool { + return typedGovernanceTool( + createRepositoryRulesetLegacy(t), + createdRulesetOutputSchema(), + decodeGovernanceJSON[RulesetOutput], + normalizeGovernanceLevelRouting, + ) +} + +func createRepositoryRulesetLegacy(t translations.TranslationHelperFunc) inventory.ServerTool { + properties := rulesetWriteProperties() + properties["level"] = &jsonschema.Schema{ + Type: "string", + Enum: []any{"repository", "organization", "enterprise"}, + Description: rulesetLevelDescription, + } + properties["owner"] = &jsonschema.Schema{Type: "string", Description: "Repository owner. Required when level is 'repository'."} + properties["repo"] = &jsonschema.Schema{Type: "string", Description: "Repository name. Required when level is 'repository'."} + properties["org"] = &jsonschema.Schema{Type: "string", Description: "Organization name. Required when level is 'organization'."} + properties["enterprise"] = &jsonschema.Schema{Type: "string", Description: "Enterprise slug. Required when level is 'enterprise'."} + + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "create_repository_ruleset", + Description: t("TOOL_CREATE_REPOSITORY_RULESET_DESCRIPTION", "Create a new ruleset at the repository, organization, or enterprise level"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_REPOSITORY_RULESET_USER_TITLE", "Create repository ruleset"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: properties, + Required: []string{"level", "name", "enforcement", "rules"}, + }, + }, + rulesetWriteScopeAccess(), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + level, err := RequiredParam[string](args, "level") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ruleset, errResult := buildRepositoryRulesetFromArgs(args) + if errResult != nil { + return errResult, nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + switch level { + case "repository": + 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 + } + created, resp, err := client.Repositories.CreateRuleset(ctx, owner, repo, ruleset) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create repository ruleset", resp, err), nil, nil + } + return MarshalledTextResult(created), nil, nil + case "organization": + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + created, resp, err := client.Organizations.CreateRepositoryRuleset(ctx, org, ruleset) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create organization repository ruleset", resp, err), nil, nil + } + return MarshalledTextResult(created), nil, nil + case "enterprise": + enterprise, err := RequiredParam[string](args, "enterprise") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + created, resp, err := client.Enterprise.CreateRepositoryRuleset(ctx, enterprise, ruleset) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create enterprise repository ruleset", resp, err), nil, nil + } + return MarshalledTextResult(created), nil, nil + default: + return utils.NewToolResultError(fmt.Sprintf("unknown level: %q (expected 'repository', 'organization', or 'enterprise')", level)), nil, nil + } + }, + ) +} + +// rulesetWriteProperties returns the shared input schema properties for the +// ruleset creation tool. Callers add the level-specific identifier +// properties (owner/repo, org, or enterprise). +func rulesetWriteProperties() map[string]*jsonschema.Schema { + return map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "The name of the ruleset", + }, + "enforcement": { + Type: "string", + Enum: []any{"disabled", "active", "evaluate"}, + Description: "The enforcement level of the ruleset. 'evaluate' allows admins to test rules before enforcing them", + }, + "target": { + Type: "string", + Enum: []any{"branch", "tag", "push", "repository"}, + Description: "The target of the ruleset. Defaults to 'branch'. 'repository' is only valid for 'organization' and 'enterprise' level rulesets.", + }, + "rules": { + Type: "array", + Description: "An array of rules within the ruleset. Each rule is an object with a 'type' (e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks') and, for rules that need configuration, a 'parameters' object", + Items: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "type": { + Type: "string", + Description: "The type of rule, e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks'", + }, + "parameters": { + Type: "object", + Description: "Parameters for rule types that require additional configuration", + }, + }, + Required: []string{"type"}, + }, + }, + "conditions": { + Type: "object", + Description: "Conditions for when this ruleset applies, e.g. {\"ref_name\": {\"include\": [\"refs/heads/main\"], \"exclude\": []}}", + }, + "bypass_actors": { + Type: "array", + Description: "The actors that can bypass the rules in this ruleset", + Items: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "actor_id": { + Type: "number", + Description: "The ID of the actor that can bypass a ruleset", + }, + "actor_type": { + Type: "string", + Enum: []any{"Integration", "OrganizationAdmin", "RepositoryRole", "Team", "DeployKey", "User", "EnterpriseOwner", "EnterpriseRole"}, + Description: "The type of actor that can bypass a ruleset. 'EnterpriseOwner' and 'EnterpriseRole' are only valid for 'enterprise' level rulesets.", + }, + "bypass_mode": { + Type: "string", + Enum: []any{"always", "pull_request", "exempt"}, + Description: "When the specified actor can bypass the ruleset. 'pull_request' only applies to branch rulesets and is not valid for the 'DeployKey' actor type. 'exempt' means rules are not run for that actor and no bypass audit entry is created.", + }, + }, + Required: []string{"actor_type", "bypass_mode"}, + }, + }, + } +} + +// buildRepositoryRulesetFromArgs assembles a github.RepositoryRuleset from the +// shared ruleset creation arguments. It returns a non-nil *mcp.CallToolResult +// describing the problem when the arguments are invalid. +func buildRepositoryRulesetFromArgs(args map[string]any) (github.RepositoryRuleset, *mcp.CallToolResult) { + name, err := RequiredParam[string](args, "name") + if err != nil { + return github.RepositoryRuleset{}, utils.NewToolResultError(err.Error()) + } + enforcement, err := RequiredParam[string](args, "enforcement") + if err != nil { + return github.RepositoryRuleset{}, utils.NewToolResultError(err.Error()) + } + target, err := OptionalParam[string](args, "target") + if err != nil { + return github.RepositoryRuleset{}, utils.NewToolResultError(err.Error()) + } + + rules, ok := args["rules"].([]any) + if !ok { + return github.RepositoryRuleset{}, utils.NewToolResultError("rules parameter must be an array of rule objects") + } + + requestedRuleTypes := make([]string, 0, len(rules)) + requestedRuleParameters := make(map[string]map[string]any, len(rules)) + seenRuleTypes := make(map[string]bool, len(rules)) + for i, rule := range rules { + ruleMap, ok := rule.(map[string]any) + if !ok { + return github.RepositoryRuleset{}, utils.NewToolResultError("each rule must be an object with a 'type' field") + } + for key := range ruleMap { + if key != "type" && key != "parameters" { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("rules[%d]: unsupported or unrecognized key: %q", i, key)) + } + } + ruleType, ok := ruleMap["type"].(string) + if !ok || ruleType == "" { + return github.RepositoryRuleset{}, utils.NewToolResultError("each rule must have a non-empty string 'type' field") + } + if seenRuleTypes[ruleType] { + // go-github stores only one rule per type. + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("duplicate rule type: %q (a ruleset may only have one rule of each type)", ruleType)) + } + seenRuleTypes[ruleType] = true + requestedRuleTypes = append(requestedRuleTypes, ruleType) + if parameters, exists := ruleMap["parameters"]; exists && parameters != nil { + parametersMap, ok := parameters.(map[string]any) + if !ok { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("rule %q: parameters must be an object", ruleType)) + } + requestedRuleParameters[ruleType] = parametersMap + } + } + + payload := map[string]any{ + "name": name, + "enforcement": enforcement, + "rules": rules, + } + if target != "" { + payload["target"] = target + } + if conditions, exists := args["conditions"]; exists && conditions != nil { + conditionsMap, ok := conditions.(map[string]any) + if !ok { + return github.RepositoryRuleset{}, utils.NewToolResultError("conditions parameter must be an object") + } + payload["conditions"] = conditionsMap + } + if bypassActors, exists := args["bypass_actors"]; exists && bypassActors != nil { + bypassActorsArr, ok := bypassActors.([]any) + if !ok { + return github.RepositoryRuleset{}, utils.NewToolResultError("bypass_actors parameter must be an array of objects") + } + for i, actor := range bypassActorsArr { + actorMap, ok := actor.(map[string]any) + if !ok { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("bypass_actors[%d] must be an object", i)) + } + for key := range actorMap { + if key != "actor_id" && key != "actor_type" && key != "bypass_mode" { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("bypass_actors[%d]: unsupported or unrecognized key: %q", i, key)) + } + } + bypassMode, ok := actorMap["bypass_mode"].(string) + if !ok { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("bypass_actors[%d].bypass_mode is required and must be a string", i)) + } + switch github.BypassMode(bypassMode) { + case github.BypassModeAlways, github.BypassModePullRequest, github.BypassModeExempt: + default: + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("bypass_actors[%d].bypass_mode must be one of \"always\", \"pull_request\", or \"exempt\"", i)) + } + } + payload["bypass_actors"] = bypassActorsArr + } + + raw, err := json.Marshal(payload) + if err != nil { + return github.RepositoryRuleset{}, utils.NewToolResultErrorFromErr("failed to build ruleset request", err) + } + var ruleset github.RepositoryRuleset + if err := json.Unmarshal(raw, &ruleset); err != nil { + return github.RepositoryRuleset{}, utils.NewToolResultErrorFromErr("failed to parse ruleset request", err) + } + + // Reject fields silently discarded by go-github's custom ruleset unmarshaler. + appliedRules, errResult := rulesetAppliedRules(ruleset.Rules) + if errResult != nil { + return github.RepositoryRuleset{}, errResult + } + for _, ruleType := range requestedRuleTypes { + appliedParameters, ok := appliedRules[ruleType] + if !ok { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("unsupported or unrecognized rule type: %q", ruleType)) + } + if requested := requestedRuleParameters[ruleType]; requested != nil { + if droppedPath := droppedKeyPath(requested, appliedParameters); droppedPath != "" { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("rule %q: unsupported or unrecognized parameter: %q", ruleType, droppedPath)) + } + } + } + + if requestedConditions, ok := payload["conditions"].(map[string]any); ok { + appliedConditions, errResult := rulesetAppliedConditions(ruleset.Conditions) + if errResult != nil { + return github.RepositoryRuleset{}, errResult + } + if droppedPath := droppedKeyPath(requestedConditions, appliedConditions); droppedPath != "" { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("conditions: unsupported or unrecognized key: %q", droppedPath)) + } + } + + return ruleset, nil +} + +// droppedKeyPath returns the first caller-supplied field omitted by the local +// go-github JSON round-trip. JSON zero values may disappear through omitempty. +func droppedKeyPath(requested, applied map[string]any) string { + for key, requestedValue := range requested { + appliedValue, ok := applied[key] + if !ok { + if isZeroJSONValue(requestedValue) { + continue + } + return key + } + if nested := droppedValuePath(requestedValue, appliedValue); nested != "" { + return key + nested + } + } + return "" +} + +func droppedValuePath(requested, applied any) string { + switch requestedTyped := requested.(type) { + case map[string]any: + appliedMap, ok := applied.(map[string]any) + if !ok { + return "" + } + if nested := droppedKeyPath(requestedTyped, appliedMap); nested != "" { + return "." + nested + } + return "" + case []any: + appliedArr, ok := applied.([]any) + if !ok { + return "" + } + for i, requestedElem := range requestedTyped { + if i >= len(appliedArr) { + if isZeroJSONValue(requestedElem) { + continue + } + return fmt.Sprintf("[%d]", i) + } + if nested := droppedValuePath(requestedElem, appliedArr[i]); nested != "" { + return fmt.Sprintf("[%d]%s", i, nested) + } + } + return "" + default: + return "" + } +} + +func isZeroJSONValue(v any) bool { + switch value := v.(type) { + case nil: + return true + case bool: + return !value + case float64: + return value == 0 + case string: + return value == "" + case []any: + return len(value) == 0 + case map[string]any: + return len(value) == 0 + default: + return false + } +} + +// rulesetAppliedRules marshals the parsed rules back to the API's array form +// and returns, for each rule type that was actually retained, the parameter +// object the corresponding go-github struct recognized. +func rulesetAppliedRules(rules *github.RepositoryRulesetRules) (map[string]map[string]any, *mcp.CallToolResult) { + applied := map[string]map[string]any{} + if rules == nil { + return applied, nil + } + raw, err := json.Marshal(rules) + if err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to validate ruleset rules", err) + } + var ruleObjects []struct { + Type string `json:"type"` + Parameters map[string]any `json:"parameters"` + } + if err := json.Unmarshal(raw, &ruleObjects); err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to validate ruleset rules", err) + } + for _, rule := range ruleObjects { + parameters := rule.Parameters + if parameters == nil { + parameters = map[string]any{} + } + applied[rule.Type] = parameters + } + return applied, nil +} + +// rulesetAppliedConditions marshals the parsed conditions back to the API's +// object form and returns the keys that were actually retained. +func rulesetAppliedConditions(conditions *github.RepositoryRulesetConditions) (map[string]any, *mcp.CallToolResult) { + if conditions == nil { + return map[string]any{}, nil + } + raw, err := json.Marshal(conditions) + if err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to validate ruleset conditions", err) + } + var applied map[string]any + if err := json.Unmarshal(raw, &applied); err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to validate ruleset conditions", err) + } + if applied == nil { + applied = map[string]any{} + } + return applied, nil +} diff --git a/pkg/github/rulesets_test.go b/pkg/github/rulesets_test.go new file mode 100644 index 0000000000..ca49482967 --- /dev/null +++ b/pkg/github/rulesets_test.go @@ -0,0 +1,1403 @@ +package github + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "testing" + + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" +) + +func Test_RepositoryRulesetRead(t *testing.T) { + toolDef := RepositoryRulesetRead(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "repository_ruleset_read", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint) + + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.ElementsMatch(t, []string{"level", "method"}, schema.Required) + assert.Equal(t, "string", schema.Properties["level"].Type) + assert.Equal(t, "string", schema.Properties["method"].Type) + + t.Run("repository level: get defaults includes_parents to true", func(t *testing.T) { + var capturedQuery url.Values + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/rulesets/{ruleset_id}": func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.Query() + mockResponse(t, http.StatusOK, &github.RepositoryRuleset{Name: "main protection", Enforcement: "active"})(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "get", "owner": "owner", "repo": "repo", "ruleset_id": float64(42)}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, "true", capturedQuery.Get("includes_parents")) + + var returned github.RepositoryRuleset + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + assert.Equal(t, "main protection", returned.Name) + }) + + t.Run("repository level: get forwards explicit includes_parents=false", func(t *testing.T) { + var capturedQuery url.Values + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/rulesets/{ruleset_id}": func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.Query() + mockResponse(t, http.StatusOK, &github.RepositoryRuleset{Name: "rs"})(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "get", "owner": "owner", "repo": "repo", "ruleset_id": float64(42), "includes_parents": false}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, "false", capturedQuery.Get("includes_parents")) + }) + + t.Run("repository level: get requires ruleset_id", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "get", "owner": "owner", "repo": "repo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "ruleset_id") + }) + + t.Run("repository level: requires owner and repo", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "list"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "owner") + }) + + t.Run("repository level: list omits includes_parents when not provided", func(t *testing.T) { + var capturedQuery url.Values + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.Query() + mockResponse(t, http.StatusOK, []*github.RepositoryRuleset{{Name: "rs1"}})(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "list", "owner": "owner", "repo": "repo", "perPage": float64(50)}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.False(t, capturedQuery.Has("includes_parents"), "includes_parents must not be sent when omitted") + assert.Equal(t, "50", capturedQuery.Get("per_page")) + + var returned []*github.RepositoryRuleset + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + require.Len(t, returned, 1) + assert.Equal(t, "rs1", returned[0].Name) + }) + + t.Run("repository level: list forwards explicit includes_parents=false", func(t *testing.T) { + var capturedQuery url.Values + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.Query() + mockResponse(t, http.StatusOK, []*github.RepositoryRuleset{})(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "list", "owner": "owner", "repo": "repo", "includes_parents": false}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, "false", capturedQuery.Get("includes_parents")) + }) + + t.Run("repository level: get_rules_for_branch", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/rules/branches/{branch}": mockResponse(t, http.StatusOK, []map[string]any{{"type": "creation"}}), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "get_rules_for_branch", "owner": "owner", "repo": "repo", "branch": "main"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "Creation") + }) + + t.Run("repository level: get_rules_for_branch requires branch", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "get_rules_for_branch", "owner": "owner", "repo": "repo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "branch") + }) + + t.Run("repository level: list_rule_suites forwards filters", func(t *testing.T) { + var capturedQuery url.Values + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/rulesets/rule-suites": func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.Query() + mockResponse(t, http.StatusOK, []map[string]any{{"id": 101, "result": "pass"}})(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "method": "list_rule_suites", + "owner": "owner", + "repo": "repo", + "ref": "refs/heads/main", + "time_period": "week", + "actor_name": "octocat", + "rule_suite_result": "pass", + "evaluate_status": "evaluate", + "perPage": float64(25), + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "pass") + + assert.Equal(t, "refs/heads/main", capturedQuery.Get("ref")) + assert.Equal(t, "week", capturedQuery.Get("time_period")) + assert.Equal(t, "octocat", capturedQuery.Get("actor_name")) + assert.Equal(t, "pass", capturedQuery.Get("rule_suite_result")) + assert.Equal(t, "evaluate", capturedQuery.Get("evaluate_status")) + assert.Equal(t, "25", capturedQuery.Get("per_page")) + }) + + t.Run("repository level: get_rule_suite", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/rulesets/rule-suites/{rule_suite_id}": mockResponse(t, http.StatusOK, map[string]any{"id": 101, "result": "fail"}), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "get_rule_suite", "owner": "owner", "repo": "repo", "rule_suite_id": float64(101)}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "fail") + }) + + t.Run("repository level: unknown method", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "frobnicate", "owner": "owner", "repo": "repo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "unknown method") + }) + + t.Run("organization level: get", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/rulesets/{ruleset_id}": mockResponse(t, http.StatusOK, &github.RepositoryRuleset{Name: "org rs", Enforcement: "active"}), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization", "method": "get", "org": "octo", "ruleset_id": float64(7)}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned github.RepositoryRuleset + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + assert.Equal(t, "org rs", returned.Name) + }) + + t.Run("organization level: get requires ruleset_id", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization", "method": "get", "org": "octo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "ruleset_id") + }) + + t.Run("organization level: requires org", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization", "method": "list"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "org") + }) + + t.Run("organization level: list", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/rulesets": mockResponse(t, http.StatusOK, []*github.RepositoryRuleset{{Name: "org rs"}}), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization", "method": "list", "org": "octo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned []*github.RepositoryRuleset + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + require.Len(t, returned, 1) + assert.Equal(t, "org rs", returned[0].Name) + }) + + t.Run("organization level: list_rule_suites forwards organization filters", func(t *testing.T) { + var capturedQuery url.Values + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/rulesets/rule-suites": func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.Query() + mockResponse(t, http.StatusOK, []map[string]any{{"id": 101, "repository_name": "repo"}})(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "organization", + "method": "list_rule_suites", + "org": "octo", + "repository_name": "repo", + "evaluate_status": "active", + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, "repo", capturedQuery.Get("repository_name")) + assert.Equal(t, "active", capturedQuery.Get("evaluate_status")) + }) + + t.Run("organization level: get_rule_suite", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/rulesets/rule-suites/{rule_suite_id}": mockResponse(t, http.StatusOK, map[string]any{"id": 101, "result": "pass"}), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization", "method": "get_rule_suite", "org": "octo", "rule_suite_id": float64(101)}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "pass") + }) + + t.Run("organization level: repository-only method is rejected", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization", "method": "get_rules_for_branch", "org": "octo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "not supported for level \"organization\"") + }) + + t.Run("organization level: unknown method", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization", "method": "frobnicate", "org": "octo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "not supported for level") + }) + + t.Run("enterprise level: get", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /enterprises/{enterprise}/rulesets/{ruleset_id}": mockResponse(t, http.StatusOK, &github.RepositoryRuleset{Name: "enterprise rs", Enforcement: "active"}), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "enterprise", "method": "get", "enterprise": "acme", "ruleset_id": float64(9)}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned github.RepositoryRuleset + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + assert.Equal(t, "enterprise rs", returned.Name) + }) + + t.Run("enterprise level: requires enterprise", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "enterprise", "method": "list"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "enterprise") + }) + + t.Run("enterprise level: list", func(t *testing.T) { + t.Run("success preserves total count and pagination", func(t *testing.T) { + var capturedQuery url.Values + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /enterprises/{enterprise}/rulesets": func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.Query() + mockResponse(t, http.StatusOK, map[string]any{ + "total_count": 17, + "rulesets": []*github.RepositoryRuleset{{Name: "enterprise rs"}}, + })(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "enterprise", + "method": "list", + "enterprise": "acme", + "page": float64(2), + "perPage": float64(1), + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, "2", capturedQuery.Get("page")) + assert.Equal(t, "1", capturedQuery.Get("per_page")) + + var returned struct { + TotalCount int `json:"total_count"` + Rulesets []*github.RepositoryRuleset `json:"rulesets"` + } + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + assert.Equal(t, 17, returned.TotalCount) + require.Len(t, returned.Rulesets, 1) + assert.Equal(t, "enterprise rs", returned.Rulesets[0].Name) + }) + + t.Run("empty response preserves object shape", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /enterprises/{enterprise}/rulesets": mockResponse(t, http.StatusOK, map[string]any{ + "total_count": 0, + "rulesets": []*github.RepositoryRuleset{}, + }), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "enterprise", "method": "list", "enterprise": "acme"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned struct { + TotalCount int `json:"total_count"` + Rulesets []*github.RepositoryRuleset `json:"rulesets"` + } + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + assert.Zero(t, returned.TotalCount) + assert.Empty(t, returned.Rulesets) + }) + + t.Run("API error", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /enterprises/{enterprise}/rulesets": mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "Internal Server Error"}), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "enterprise", "method": "list", "enterprise": "acme"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "failed to list enterprise repository rulesets") + }) + }) + + t.Run("enterprise level: rule suite method is rejected", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "enterprise", "method": "list_rule_suites", "enterprise": "acme"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "not supported for level \"enterprise\"") + }) + + t.Run("unknown level defers to normal validation", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "planet", "method": "list"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "unknown level") + }) + + t.Run("mismatched-case level is rejected rather than silently normalized", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/rulesets": func(w http.ResponseWriter, r *http.Request) { + called = true + mockResponse(t, http.StatusOK, []*github.RepositoryRuleset{})(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "Organization", "method": "list", "org": "octo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "unknown level") + assert.False(t, called, "a mismatched-case level must not reach the organization-level API call") + + assert.Empty(t, toolDef.ScopeAccess.Challenge(map[string]any{"level": "Organization"}, nil)) + }) +} + +func Test_GetRepositoryRulesForBranchEscapesBranch(t *testing.T) { + tests := []struct { + name string + branch string + escapedPath string + }{ + { + name: "slash", + branch: "release/1.0", + escapedPath: "/repos/owner/repo/rules/branches/release%2F1.0", + }, + { + name: "special characters", + branch: "release/#1%ready", + escapedPath: "/repos/owner/repo/rules/branches/release%2F%231%25ready", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, tt.escapedPath, r.URL.EscapedPath()) + mockResponse(t, http.StatusOK, []map[string]any{{"type": "creation"}})(w, r) + })) + + result, err := GetRepositoryRulesForBranch(t.Context(), client, "owner", "repo", tt.branch, PaginationParams{}) + require.NoError(t, err) + require.False(t, result.IsError) + }) + } +} + +func Test_CreateRepositoryRuleset(t *testing.T) { + toolDef := CreateRepositoryRuleset(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "create_repository_ruleset", toolDef.Tool.Name) + assert.False(t, toolDef.Tool.Annotations.ReadOnlyHint) + + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok) + assert.ElementsMatch(t, []string{"level", "name", "enforcement", "rules"}, schema.Required) + require.NotNil(t, schema.AdditionalProperties) + assert.NotNil(t, schema.AdditionalProperties.Not) + + propertyNames := make([]string, 0, len(schema.Properties)) + for name := range schema.Properties { + propertyNames = append(propertyNames, name) + } + assert.ElementsMatch(t, []string{ + "level", + "owner", + "repo", + "org", + "enterprise", + "name", + "enforcement", + "target", + "rules", + "conditions", + "bypass_actors", + }, propertyNames) + + bypassActorSchema := schema.Properties["bypass_actors"].Items + require.NotNil(t, bypassActorSchema) + assert.ElementsMatch(t, []string{"actor_type", "bypass_mode"}, bypassActorSchema.Required) + assert.ElementsMatch(t, []any{"always", "pull_request", "exempt"}, bypassActorSchema.Properties["bypass_mode"].Enum) + resolvedActorSchema, err := bypassActorSchema.Resolve(nil) + require.NoError(t, err) + assert.Error(t, resolvedActorSchema.Validate(map[string]any{"actor_type": "OrganizationAdmin"})) + assert.Error(t, resolvedActorSchema.Validate(map[string]any{"actor_type": "OrganizationAdmin", "bypass_mode": "never"})) + + t.Run("repository level", func(t *testing.T) { + var capturedBody github.RepositoryRuleset + var capturedRaw []byte + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + capturedRaw = body + _ = json.Unmarshal(body, &capturedBody) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(body) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "main protection", + "enforcement": "active", + "target": "branch", + "rules": []any{ + map[string]any{"type": "creation"}, + map[string]any{"type": "deletion"}, + map[string]any{ + "type": "pull_request", + "parameters": map[string]any{ + "required_approving_review_count": float64(2), + }, + }, + }, + "conditions": map[string]any{ + "ref_name": map[string]any{ + "include": []any{"refs/heads/main"}, + "exclude": []any{}, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assert.Equal(t, "main protection", capturedBody.Name) + assert.Equal(t, github.RulesetEnforcement("active"), capturedBody.Enforcement) + require.NotNil(t, capturedBody.Rules) + + var outbound struct { + Rules []struct { + Type string `json:"type"` + Parameters map[string]any `json:"parameters"` + } `json:"rules"` + Conditions struct { + RefName struct { + Include []string `json:"include"` + } `json:"ref_name"` + } `json:"conditions"` + } + require.NoError(t, json.Unmarshal(capturedRaw, &outbound)) + + sentTypes := make([]string, 0, len(outbound.Rules)) + var pullRequestParams map[string]any + for _, rule := range outbound.Rules { + sentTypes = append(sentTypes, rule.Type) + if rule.Type == "pull_request" { + pullRequestParams = rule.Parameters + } + } + assert.ElementsMatch(t, []string{"creation", "deletion", "pull_request"}, sentTypes) + require.NotNil(t, pullRequestParams) + assert.EqualValues(t, 2, pullRequestParams["required_approving_review_count"]) + assert.Equal(t, []string{"refs/heads/main"}, outbound.Conditions.RefName.Include) + }) + + t.Run("repository level requires owner and repo", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "name": "x", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "owner") + }) + + t.Run("unsupported rule type", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{ + map[string]any{"type": "creation"}, + map[string]any{"type": "totally_made_up_rule"}, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "totally_made_up_rule") + assert.False(t, called, "request must not be sent when a rule type is unsupported") + }) + + t.Run("unrecognized rule key", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{ + map[string]any{ + "type": "pull_request", + "configuration": map[string]any{"required_approving_review_count": float64(2)}, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "configuration") + assert.False(t, called) + }) + + t.Run("invalid rules", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": "not-an-array", + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "rules parameter must be an array") + }) + + t.Run("duplicate rule type", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{ + map[string]any{"type": "creation"}, + map[string]any{"type": "creation"}, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "duplicate rule type") + assert.False(t, called, "request must not be sent when rules contain a duplicate type") + }) + + t.Run("unrecognized rule parameter is rejected even though the rule type is valid", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{ + map[string]any{ + "type": "pull_request", + "parameters": map[string]any{ + "require_code_owners_review": true, // typo: should be require_code_owner_review + }, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "require_code_owners_review") + assert.False(t, called, "request must not be sent when a rule parameter is unrecognized") + }) + + t.Run("zero-valued parameters are not flagged as unrecognized", func(t *testing.T) { + var capturedBody []byte + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(capturedBody) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{ + map[string]any{ + "type": "pull_request", + "parameters": map[string]any{ + "required_approving_review_count": float64(0), + "allowed_merge_methods": []any{}, + }, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + if result.IsError { + t.Fatalf("unexpected error: %s", getErrorResult(t, result).Text) + } + assert.NotEmpty(t, capturedBody) + }) + + t.Run("unrecognized key inside a rule parameter array element is rejected", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{ + map[string]any{ + "type": "required_status_checks", + "parameters": map[string]any{ + "required_status_checks": []any{ + map[string]any{ + "context": "ci", + "integration_ids": float64(42), // typo: should be integration_id + }, + }, + "strict_required_status_checks_policy": true, + }, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "required_status_checks[0].integration_ids") + assert.False(t, called, "request must not be sent when a nested array element key is unrecognized") + }) + + t.Run("valid rule parameter array elements round-trip and are not misflagged", func(t *testing.T) { + var capturedBody []byte + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(capturedBody) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{ + map[string]any{ + "type": "required_status_checks", + "parameters": map[string]any{ + "required_status_checks": []any{ + map[string]any{ + "context": "ci", + "integration_id": float64(42), + }, + }, + "strict_required_status_checks_policy": true, + }, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + if result.IsError { + t.Fatalf("unexpected error: %s", getErrorResult(t, result).Text) + } + assert.NotEmpty(t, capturedBody) + }) + + for _, bypassMode := range []string{"always", "pull_request", "exempt"} { + t.Run("bypass_actors serializes "+bypassMode+" exactly", func(t *testing.T) { + var capturedRaw []byte + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) { + capturedRaw, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(capturedRaw) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "main protection", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + "bypass_actors": []any{ + map[string]any{"actor_type": "OrganizationAdmin", "bypass_mode": bypassMode}, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var outbound struct { + BypassActors []struct { + BypassMode string `json:"bypass_mode"` + } `json:"bypass_actors"` + } + require.NoError(t, json.Unmarshal(capturedRaw, &outbound)) + require.Len(t, outbound.BypassActors, 1) + assert.Equal(t, bypassMode, outbound.BypassActors[0].BypassMode) + }) + } + + t.Run("bypass_actors without bypass_mode is rejected before request", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "main protection", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + "bypass_actors": []any{ + map[string]any{"actor_type": "OrganizationAdmin"}, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "bypass_actors[0].bypass_mode") + assert.False(t, called) + }) + + t.Run("bypass_actors accepts exempt bypass mode and enterprise actor types", func(t *testing.T) { + var capturedBody github.RepositoryRuleset + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /enterprises/{enterprise}/rulesets": func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &capturedBody) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(body) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "enterprise", + "enterprise": "acme", + "name": "enterprise protection", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + "bypass_actors": []any{ + map[string]any{"actor_type": "EnterpriseOwner", "bypass_mode": "exempt"}, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + require.Len(t, capturedBody.BypassActors, 1) + assert.Equal(t, github.BypassActorType("EnterpriseOwner"), *capturedBody.BypassActors[0].ActorType) + assert.Equal(t, github.BypassMode("exempt"), *capturedBody.BypassActors[0].BypassMode) + }) + + t.Run("unrecognized bypass_actors key is rejected", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + "bypass_actors": []any{ + map[string]any{"actor_type": "Team", "actor_id": float64(1), "bypass_modes": "pull_request"}, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "bypass_modes") + assert.False(t, called, "request must not be sent when a bypass_actors key is unrecognized") + }) + + t.Run("unrecognized top-level condition key is rejected", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + "conditions": map[string]any{ + "ref_names": map[string]any{ // typo: should be ref_name + "include": []any{"refs/heads/main"}, + "exclude": []any{}, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "ref_names") + assert.False(t, called, "request must not be sent when a condition key is unrecognized") + }) + + t.Run("unrecognized nested condition key is rejected", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + "conditions": map[string]any{ + "ref_name": map[string]any{ + "includes": []any{"refs/heads/main"}, // typo: should be include + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "ref_name.includes") + assert.False(t, called, "request must not be sent when a nested condition key is unrecognized") + }) + + t.Run("valid conditions round-trip and are not misflagged", func(t *testing.T) { + var capturedBody []byte + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(capturedBody) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + "conditions": map[string]any{ + "ref_name": map[string]any{ + "include": []any{"refs/heads/main"}, + "exclude": []any{}, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + if result.IsError { + t.Fatalf("unexpected error: %s", getErrorResult(t, result).Text) + } + assert.NotEmpty(t, capturedBody) + }) + + t.Run("organization level", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /orgs/{org}/rulesets": func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(body) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "organization", + "org": "octo", + "name": "org protection", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned github.RepositoryRuleset + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + assert.Equal(t, "org protection", returned.Name) + }) + + t.Run("organization level requires org", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "organization", + "name": "org protection", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "org") + }) + + t.Run("enterprise level", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /enterprises/{enterprise}/rulesets": func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(body) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "enterprise", + "enterprise": "acme", + "name": "enterprise protection", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned github.RepositoryRuleset + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + assert.Equal(t, "enterprise protection", returned.Name) + }) + + t.Run("enterprise level requires enterprise", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "enterprise", + "name": "enterprise protection", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "enterprise") + }) + + t.Run("unknown level", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "planet", + "name": "x", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "unknown level") + }) + + t.Run("mismatched-case level is rejected rather than silently normalized", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /orgs/{org}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "Organization", + "org": "octo", + "name": "org protection", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "unknown level") + assert.False(t, called, "a mismatched-case level must not reach the organization-level create call") + + assert.Empty(t, toolDef.ScopeAccess.Challenge(map[string]any{"level": "Organization"}, nil)) + }) +} + +func Test_RulesetScopeChallenges(t *testing.T) { + tests := []struct { + name string + tool inventory.ServerTool + arguments map[string]any + allowed []string + disallowed []string + }{ + { + name: "read repository level", + tool: RepositoryRulesetRead(translations.NullTranslationHelper), + arguments: map[string]any{"level": "repository", "method": "get"}, + allowed: []string{"repo"}, + disallowed: []string{"read:org"}, + }, + { + name: "read organization level", + tool: RepositoryRulesetRead(translations.NullTranslationHelper), + arguments: map[string]any{"level": "organization", "method": "list"}, + allowed: []string{"read:org"}, + disallowed: []string{"repo"}, + }, + { + name: "read enterprise level", + tool: RepositoryRulesetRead(translations.NullTranslationHelper), + arguments: map[string]any{"level": "enterprise", "method": "get"}, + allowed: []string{"read:enterprise"}, + disallowed: []string{"repo", "read:org"}, + }, + { + name: "read missing level defers to validation", + tool: RepositoryRulesetRead(translations.NullTranslationHelper), + arguments: map[string]any{"method": "get"}, + allowed: nil, + disallowed: nil, + }, + { + name: "read unknown level defers to validation", + tool: RepositoryRulesetRead(translations.NullTranslationHelper), + arguments: map[string]any{"level": "planet", "method": "get"}, + allowed: nil, + disallowed: nil, + }, + { + name: "write repository level", + tool: CreateRepositoryRuleset(translations.NullTranslationHelper), + arguments: map[string]any{"level": "repository"}, + allowed: []string{"repo"}, + disallowed: []string{"admin:org"}, + }, + { + name: "write organization level", + tool: CreateRepositoryRuleset(translations.NullTranslationHelper), + arguments: map[string]any{"level": "organization"}, + allowed: []string{"admin:org"}, + disallowed: []string{"repo"}, + }, + { + name: "write enterprise level", + tool: CreateRepositoryRuleset(translations.NullTranslationHelper), + arguments: map[string]any{"level": "enterprise"}, + allowed: []string{"admin:enterprise"}, + disallowed: []string{"repo", "admin:org"}, + }, + { + name: "write missing level defers to validation", + tool: CreateRepositoryRuleset(translations.NullTranslationHelper), + arguments: map[string]any{}, + allowed: nil, + disallowed: nil, + }, + { + name: "write malformed level defers to validation", + tool: CreateRepositoryRuleset(translations.NullTranslationHelper), + arguments: map[string]any{"level": 123}, + allowed: nil, + disallowed: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.NotNil(t, tt.tool.ScopeAccess.Challenge) + assert.True(t, tt.tool.ScopeAccess.Dynamic) + assert.Empty(t, tt.tool.ScopeAccess.Challenge(tt.arguments, tt.allowed)) + if tt.disallowed == nil { + assert.Empty(t, tt.tool.ScopeAccess.Challenge(tt.arguments, nil)) + } else { + assert.NotEmpty(t, tt.tool.ScopeAccess.Challenge(tt.arguments, tt.disallowed)) + } + }) + } +} + +func Test_RulesetScopeVisibility(t *testing.T) { + readAccess := RepositoryRulesetRead(translations.NullTranslationHelper).ScopeAccess + writeAccess := CreateRepositoryRuleset(translations.NullTranslationHelper).ScopeAccess + + assert.True(t, readAccess.Visible(nil)) + assert.False(t, writeAccess.Visible(nil)) + assert.True(t, writeAccess.Visible([]string{"repo"})) + assert.True(t, writeAccess.Visible([]string{"admin:org"})) + assert.True(t, writeAccess.Visible([]string{"admin:enterprise"})) + assert.False(t, writeAccess.Visible([]string{"read:org", "read:enterprise"})) +} + +func Test_RulesetScopeMetadataIsExhaustive(t *testing.T) { + tests := []struct { + tool inventory.ServerTool + maxScopes []string + }{ + {tool: RepositoryRulesetRead(translations.NullTranslationHelper), maxScopes: []string{"repo", "read:org", "read:enterprise"}}, + {tool: CreateRepositoryRuleset(translations.NullTranslationHelper), maxScopes: []string{"repo", "admin:org", "admin:enterprise"}}, + } + + for _, tt := range tests { + t.Run(tt.tool.Tool.Name, func(t *testing.T) { + assert.True(t, tt.tool.ScopeAccess.Dynamic) + assert.Equal(t, tt.maxScopes, tt.tool.ScopeAccess.Scopes) + assert.NotNil(t, tt.tool.ScopeAccess.Challenge) + }) + } +} diff --git a/pkg/github/sanitize_coverage_test.go b/pkg/github/sanitize_coverage_test.go index 59747e9780..f8f612cfe2 100644 --- a/pkg/github/sanitize_coverage_test.go +++ b/pkg/github/sanitize_coverage_test.go @@ -6,53 +6,49 @@ import ( "testing" "time" - "github.com/google/go-github/v89/github" + "github.com/google/go-github/v92/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// maliciousText contains an HTML payload plus invisible/hidden-instruction characters, -// mirroring the classes of untrusted content pkg/sanitize.Sanitize is meant to strip: -// disallowed HTML tags and zero-width/BiDi control characters that can hide instructions -// from a human reviewer while still being interpreted by a model. +// maliciousText contains an HTML payload plus an invisible character. const maliciousText = "Hello\u200BWorld" -// sanitizedText is what maliciousText becomes after sanitize.Sanitize: the HelloWorld" -// Test_MinimalConverters_SanitizeUserAuthoredText is a table-driven regression test asserting -// that every convertToMinimal* helper which surfaces untrusted, user-authored prose (issue and -// PR titles/bodies, comments, reviews, review comments, releases, commit messages) applies -// pkg/sanitize.Sanitize consistently. This guards against the inconsistent coverage described in -// https://github.com/github/github-mcp-server/issues/3106. +// Test_MinimalConverters_SanitizeUserAuthoredText covers the converter fields that expose +// untrusted titles, bodies, comments, reviews, releases, and commit messages. func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { tests := []struct { - name string - got func() string + name string + content bool + got func() string }{ { name: "issue title (REST)", got: func() string { return convertToMinimalIssue(&github.Issue{ - Title: github.Ptr(maliciousText), + Title: new(maliciousText), }).Title }, }, { - name: "issue body (REST)", + name: "issue body (REST)", + content: true, got: func() string { return convertToMinimalIssue(&github.Issue{ - Body: github.Ptr(maliciousText), + Body: new(maliciousText), }).Body }, }, { - name: "issue comment body", + name: "issue comment body", + content: true, got: func() string { return convertToMinimalIssueComment(&github.IssueComment{ - Body: github.Ptr(maliciousText), + Body: new(maliciousText), }).Body }, }, @@ -60,28 +56,31 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { name: "pull request title", got: func() string { return convertToMinimalPullRequest(&github.PullRequest{ - Title: github.Ptr(maliciousText), + Title: new(maliciousText), }).Title }, }, { - name: "pull request body", + name: "pull request body", + content: true, got: func() string { return convertToMinimalPullRequest(&github.PullRequest{ - Body: github.Ptr(maliciousText), + Body: new(maliciousText), }).Body }, }, { - name: "pull request review body", + name: "pull request review body", + content: true, got: func() string { return convertToMinimalPullRequestReview(&github.PullRequestReview{ - Body: github.Ptr(maliciousText), + Body: new(maliciousText), }).Body }, }, { - name: "pull request review comment body (GraphQL)", + name: "pull request review comment body (GraphQL)", + content: true, got: func() string { return convertToMinimalReviewComment(reviewCommentNode{ Body: githubv4.String(maliciousText), @@ -93,63 +92,69 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { name: "release name", got: func() string { return convertToMinimalRelease(&github.RepositoryRelease{ - Name: github.Ptr(maliciousText), + Name: new(maliciousText), }).Name }, }, { - name: "release body", + name: "release body", + content: true, got: func() string { return convertToMinimalRelease(&github.RepositoryRelease{ - Body: github.Ptr(maliciousText), + Body: new(maliciousText), }).Body }, }, { - name: "commit message (get_commit / list_commits)", + name: "commit message (get_commit / list_commits)", + content: true, got: func() string { commit := convertToMinimalCommit(&github.RepositoryCommit{ - Commit: &github.Commit{Message: github.Ptr(maliciousText)}, + Commit: &github.Commit{Message: new(maliciousText)}, }, commitDetailNone) require.NotNil(t, commit.Commit) return commit.Commit.Message }, }, { - name: "commit message (search_commits)", + name: "commit message (search_commits)", + content: true, got: func() string { item := convertCommitResultToMinimalCommit(&github.CommitResult{ - Commit: &github.Commit{Message: github.Ptr(maliciousText)}, + Commit: &github.Commit{Message: new(maliciousText)}, }) require.NotNil(t, item.Commit) return item.Commit.Message }, }, { - name: "pull request commit message (list_pull_request_commits)", + name: "pull request commit message (list_pull_request_commits)", + content: true, got: func() string { commits := convertToMinimalPullRequestCommits([]*github.RepositoryCommit{ - {Commit: &github.Commit{Message: github.Ptr(maliciousText)}}, + {Commit: &github.Commit{Message: new(maliciousText)}}, }) require.Len(t, commits, 1) return commits[0].Message }, }, { - name: "file commit message (create/update/delete file)", + name: "file commit message (create/update/delete file)", + content: true, got: func() string { resp := convertToMinimalFileContentResponse(&github.RepositoryContentResponse{ - Commit: github.Commit{Message: github.Ptr(maliciousText)}, + Commit: github.Commit{Message: new(maliciousText)}, }) require.NotNil(t, resp.Commit) return resp.Commit.Message }, }, { - name: "workflow run head commit message", + name: "workflow run head commit message", + content: true, got: func() string { run := convertToMinimalWorkflowRun(&github.WorkflowRun{ - HeadCommit: &github.HeadCommit{Message: github.Ptr(maliciousText)}, + HeadCommit: &github.HeadCommit{Message: new(maliciousText)}, }) require.NotNil(t, run.HeadCommit) return run.HeadCommit.Message @@ -159,7 +164,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { name: "project item content title (issue)", got: func() string { return convertIssueToMinimalProjectItemContent(&github.Issue{ - Title: github.Ptr(maliciousText), + Title: new(maliciousText), }).Title }, }, @@ -167,7 +172,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { name: "project item content title (pull request)", got: func() string { return convertPullRequestToMinimalProjectItemContent(&github.PullRequest{ - Title: github.Ptr(maliciousText), + Title: new(maliciousText), }).Title }, }, @@ -175,7 +180,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { name: "project item content title (draft issue)", got: func() string { return convertDraftIssueToMinimalProjectItemContent(&github.ProjectV2DraftIssue{ - Title: github.Ptr(maliciousText), + Title: new(maliciousText), }).Title }, }, @@ -183,7 +188,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { name: "project pull request ref title (from *github.PullRequest)", got: func() string { return minimalProjectPullRequestRefFromPullRequest(&github.PullRequest{ - Title: github.Ptr(maliciousText), + Title: new(maliciousText), }).Title }, }, @@ -196,7 +201,8 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { }, }, { - name: "project status update body (projects_get / projects_list)", + name: "project status update body (projects_get / projects_list)", + content: true, got: func() string { return convertToMinimalStatusUpdate(statusUpdateNode{ Body: githubv4.NewString(githubv4.String(maliciousText)), @@ -220,7 +226,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { name: "issue dependency ref title (issue_dependency_read / issue_dependency_write)", got: func() string { return issueToDependencyRef(&github.Issue{ - Title: github.Ptr(maliciousText), + Title: new(maliciousText), }).Title }, }, @@ -228,7 +234,11 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, sanitizedText, tt.got()) + expected := sanitizedText + if tt.content { + expected = sanitizedContentText + } + assert.Equal(t, expected, tt.got()) }) } } @@ -239,8 +249,8 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { func Test_SearchIssueResult_SanitizesTitleAndBody(t *testing.T) { result := SearchIssueResult{ Issue: &github.Issue{ - Title: github.Ptr(maliciousText), - Body: github.Ptr(maliciousText), + Title: new(maliciousText), + Body: new(maliciousText), }, } @@ -254,7 +264,7 @@ func Test_SearchIssueResult_SanitizesTitleAndBody(t *testing.T) { require.NoError(t, json.Unmarshal(out, &decoded)) assert.Equal(t, sanitizedText, decoded.Title) - assert.Equal(t, sanitizedText, decoded.Body) + assert.Equal(t, sanitizedContentText, decoded.Body) } // Test_SanitizeIssueTitleAndBody exercises the shared helper directly, including its nil-safety, @@ -273,14 +283,14 @@ func Test_SanitizeIssueTitleAndBody(t *testing.T) { t.Run("sanitizes in place", func(t *testing.T) { issue := &github.Issue{ - Title: github.Ptr(maliciousText), - Body: github.Ptr(maliciousText), + Title: new(maliciousText), + Body: new(maliciousText), } sanitizeIssueTitleAndBody(issue) require.NotNil(t, issue.Title) require.NotNil(t, issue.Body) assert.Equal(t, sanitizedText, *issue.Title) - assert.Equal(t, sanitizedText, *issue.Body) + assert.Equal(t, sanitizedContentText, *issue.Body) }) } @@ -292,7 +302,7 @@ func Test_MinimalConverters_PreserveCodeFidelity(t *testing.T) { t.Run("commit file patch (get_commit)", func(t *testing.T) { commit := convertToMinimalCommit(&github.RepositoryCommit{ - Files: []*github.CommitFile{{Filename: github.Ptr("a.go"), Patch: github.Ptr(patch)}}, + Files: []*github.CommitFile{{Filename: new("a.go"), Patch: new(patch)}}, }, commitDetailFullPatch) require.Len(t, commit.Files, 1) assert.Equal(t, patch, commit.Files[0].Patch) @@ -300,7 +310,7 @@ func Test_MinimalConverters_PreserveCodeFidelity(t *testing.T) { t.Run("pull request file patch (get_pull_request_files)", func(t *testing.T) { files := convertToMinimalPRFiles([]*github.CommitFile{ - {Filename: github.Ptr("a.go"), Patch: github.Ptr(patch)}, + {Filename: new("a.go"), Patch: new(patch)}, }) require.Len(t, files, 1) assert.Equal(t, patch, files[0].Patch) @@ -319,6 +329,6 @@ func Test_Discussion_SanitizesUserAuthoredText(t *testing.T) { t.Run("discussion comment body (newMinimalDiscussionComment, used by get_discussion_comments)", func(t *testing.T) { comment := newMinimalDiscussionComment("id", maliciousText, false) - assert.Equal(t, sanitizedText, comment.Body) + assert.Equal(t, sanitizedContentText, comment.Body) }) } diff --git a/pkg/github/scope_filter.go b/pkg/github/scope_filter.go index f5c1a46e38..dd944ac0a1 100644 --- a/pkg/github/scope_filter.go +++ b/pkg/github/scope_filter.go @@ -3,7 +3,7 @@ package github import ( "context" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" ) // CreateToolScopeFilter creates an inventory.ToolFilter that filters tools diff --git a/pkg/github/scope_filter_test.go b/pkg/github/scope_filter_test.go index 43ef6335b4..5ef06b2bec 100644 --- a/pkg/github/scope_filter_test.go +++ b/pkg/github/scope_filter_test.go @@ -4,9 +4,9 @@ import ( "context" "testing" - "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/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/search.go b/pkg/github/search.go index 715ca83aed..368357c4ed 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -6,14 +6,15 @@ import ( "fmt" "io" "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/v89/github" + "slices" + + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -39,7 +40,7 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo }, "minimal_output": { Type: "boolean", - Description: "Return minimal repository information (default: true). When false, returns full GitHub API repository objects.", + Description: "Return minimal repository information (default: true). When false, modern clients receive additional curated repository details, not the complete GitHub API object. Legacy clients retain full GitHub API repository objects.", Default: json.RawMessage(`true`), }, }, @@ -47,7 +48,7 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo } WithPagination(schema) - return NewTool( + return NewTool[SearchRepositoriesInput, *SearchRepositoriesOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: "search_repositories", @@ -56,33 +57,21 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo Title: t("TOOL_SEARCH_REPOSITORIES_USER_TITLE", "Search repositories"), ReadOnlyHint: true, }, - InputSchema: schema, + InputSchema: schema, + OutputSchema: searchRepositoriesOutputSchema(), }, scopes.PublicRead(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 - } - minimalOutput, err := OptionalBoolParamWithDefault(args, "minimal_output", true) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SearchRepositoriesInput) (*mcp.CallToolResult, *SearchRepositoriesOutput, error) { + var output *SearchRepositoriesOutput + if input.Query == "" { + return utils.NewToolResultError("missing required parameter: query"), output, nil + } + query := input.Query + pagination := repositoryPagination(input.Page, input.PerPage) + minimalOutput := input.MinimalOutput == nil || *input.MinimalOutput opts := &github.SearchOptions{ - Sort: sort, - Order: order, + Sort: input.Sort, + Order: input.Order, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, @@ -91,7 +80,7 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo client, err := deps.GetClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), output, nil } result, resp, err := client.Search.Repositories(ctx, query, opts) if err != nil { @@ -99,50 +88,31 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo fmt.Sprintf("failed to search repositories with query '%s'", query), resp, err, - ), nil, nil + ), output, 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 utils.NewToolResultErrorFromErr("failed to read response body", err), output, nil } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search repositories", resp, body), nil, nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search repositories", resp, body), output, nil } // Return either minimal or full response based on parameter var r []byte + output = &SearchRepositoriesOutput{ + TotalCount: result.GetTotal(), + IncompleteResults: result.GetIncompleteResults(), + Items: make([]SearchRepository, 0, len(result.Repositories)), + } if minimalOutput { minimalRepos := make([]MinimalRepository, 0, len(result.Repositories)) for _, repo := range result.Repositories { - minimalRepo := MinimalRepository{ - ID: repo.GetID(), - Name: repo.GetName(), - FullName: repo.GetFullName(), - Description: repo.GetDescription(), - HTMLURL: repo.GetHTMLURL(), - Language: repo.GetLanguage(), - Stars: repo.GetStargazersCount(), - Forks: repo.GetForksCount(), - OpenIssues: repo.GetOpenIssuesCount(), - Private: repo.GetPrivate(), - Fork: repo.GetFork(), - Archived: repo.GetArchived(), - DefaultBranch: repo.GetDefaultBranch(), - } - - if repo.UpdatedAt != nil { - minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z") - } - if repo.CreatedAt != nil { - minimalRepo.CreatedAt = repo.CreatedAt.Format("2006-01-02T15:04:05Z") - } - if repo.Topics != nil { - minimalRepo.Topics = repo.Topics - } - + minimalRepo := minimalSearchRepository(repo) minimalRepos = append(minimalRepos, minimalRepo) + output.Items = append(output.Items, SearchRepository{MinimalRepository: minimalRepo}) } minimalResult := &MinimalSearchRepositoriesResult{ @@ -160,12 +130,16 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal full response", err), nil, nil } + for _, repo := range result.Repositories { + output.Items = append(output.Items, fullSearchRepository(repo)) + } } callResult := utils.NewToolResultText(string(r)) attachSearchRepositoriesIFCLabel(ctx, deps, result.Repositories, callResult) - return callResult, nil, nil + return callResult, output, nil }, + normalizeSearchArguments(true), ) } @@ -192,6 +166,99 @@ func attachSearchRepositoriesIFCLabel(ctx context.Context, deps ToolDependencies } // SearchCode creates a tool to search for code across GitHub repositories. +type SearchCodeInput struct { + Query string `json:"query"` + Sort string `json:"sort,omitempty"` + Order string `json:"order,omitempty"` + Fields []string `json:"fields,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type SearchCodeOutput struct { + TotalCount int `json:"total_count" jsonschema:"Total number of matching code results."` + IncompleteResults bool `json:"incomplete_results" jsonschema:"Whether GitHub returned an incomplete result set."` + Items []SearchCodeOutputItem `json:"items"` +} + +type SearchCodeOutputItem struct { + Name *string `json:"name,omitempty"` + Path *string `json:"path,omitempty"` + SHA *string `json:"sha,omitempty"` + Repository *string `json:"repository,omitempty"` + TextMatches []*SearchCodeTextMatch `json:"text_matches,omitempty" jsonschema:"Matching code fragments returned by GitHub."` +} + +type SearchCodeTextMatch struct { + ObjectURL *string `json:"object_url,omitempty" jsonschema:"API URL of the matched file or object."` + ObjectType *string `json:"object_type,omitempty" jsonschema:"GitHub object type containing the match."` + Property *string `json:"property,omitempty" jsonschema:"Property containing the matched text."` + Fragment *string `json:"fragment,omitempty" jsonschema:"Source fragment containing the match."` + Matches []*SearchCodeTextMatchHit `json:"matches,omitempty" jsonschema:"Matched text and its offsets in the fragment."` +} + +type SearchCodeTextMatchHit struct { + Text *string `json:"text,omitempty" jsonschema:"Matched text."` + Indices []int `json:"indices,omitempty" jsonschema:"Start and end offsets of the match in the fragment."` +} + +func structuredSearchCodeOutput(result MinimalCodeSearchResult, fields []string) SearchCodeOutput { + output := SearchCodeOutput{ + TotalCount: result.TotalCount, + IncompleteResults: result.IncompleteResults, + Items: make([]SearchCodeOutputItem, 0, len(result.Items)), + } + for _, item := range result.Items { + selected := func(field string) bool { + return len(fields) == 0 || slices.Contains(fields, field) + } + projected := SearchCodeOutputItem{} + if selected("name") { + projected.Name = new(item.Name) + } + if selected("path") { + projected.Path = new(item.Path) + } + if selected("sha") { + projected.SHA = new(item.SHA) + } + if selected("repository") { + projected.Repository = new(item.Repository) + } + if selected("text_matches") && item.TextMatches != nil { + projected.TextMatches = make([]*SearchCodeTextMatch, 0, len(item.TextMatches)) + for _, match := range item.TextMatches { + if match == nil { + projected.TextMatches = append(projected.TextMatches, nil) + continue + } + projectedMatch := &SearchCodeTextMatch{ + ObjectURL: match.ObjectURL, + ObjectType: match.ObjectType, + Property: match.Property, + Fragment: match.Fragment, + } + if match.Matches != nil { + projectedMatch.Matches = make([]*SearchCodeTextMatchHit, 0, len(match.Matches)) + for _, hit := range match.Matches { + if hit == nil { + projectedMatch.Matches = append(projectedMatch.Matches, nil) + continue + } + projectedMatch.Matches = append(projectedMatch.Matches, &SearchCodeTextMatchHit{ + Text: hit.Text, + Indices: hit.Indices, + }) + } + } + projected.TextMatches = append(projected.TextMatches, projectedMatch) + } + } + output.Items = append(output.Items, projected) + } + return output +} + func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", @@ -218,7 +285,7 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { ) WithPagination(schema) - return NewTool( + return NewTool[SearchCodeInput, SearchCodeOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: "search_code", @@ -230,31 +297,23 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { InputSchema: schema, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SearchCodeInput) (*mcp.CallToolResult, SearchCodeOutput, error) { + var output SearchCodeOutput + if input.Query == "" { + return utils.NewToolResultError("missing required parameter: query"), output, 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 - } - fields, err := OptionalStringArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + pagination := PaginationParams{Page: 1, PerPage: 30} + if input.Page != nil { + pagination.Page = *input.Page } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.PerPage != nil { + pagination.PerPage = *input.PerPage } + query := input.Query opts := &github.SearchOptions{ - Sort: sort, - Order: order, + Sort: input.Sort, + Order: input.Order, TextMatch: true, ListOptions: github.ListOptions{ PerPage: pagination.PerPage, @@ -264,7 +323,7 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { client, err := deps.GetClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), output, nil } result, resp, err := client.Search.Code(ctx, query, opts) @@ -273,16 +332,16 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { fmt.Sprintf("failed to search code with query '%s'", query), resp, err, - ), nil, nil + ), output, 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 utils.NewToolResultErrorFromErr("failed to read response body", err), output, nil } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search code", resp, body), nil, nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search code", resp, body), output, nil } minimalItems := make([]MinimalCodeResult, 0, len(result.CodeResults)) @@ -307,10 +366,10 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { filtered := false var payload any = minimalResult - if len(fields) > 0 { - filteredItems, err := filterEachField(minimalItems, fields) + if len(input.Fields) > 0 { + filteredItems, err := filterEachField(minimalItems, input.Fields) if err != nil { - return utils.NewToolResultErrorFromErr("failed to filter code search results", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to filter code search results", err), output, nil } payload = map[string]any{ "total_count": minimalResult.TotalCount, @@ -322,7 +381,7 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { r, err := json.Marshal(payload) if err != nil { - return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to marshal response", err), output, nil } recordSearchCodeFieldsUsage(ctx, deps, minimalResult, filtered, len(r)) @@ -338,8 +397,10 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { } } callResult = attachJoinedIFCLabel(ctx, deps, callResult, visibilities, ifc.LabelSearchIssues) - return callResult, nil, nil + output = structuredSearchCodeOutput(*minimalResult, input.Fields) + return callResult, output, nil }, + normalizeTypedReadArguments(nil, false), ) } @@ -349,27 +410,16 @@ func recordSearchCodeFieldsUsage(ctx context.Context, deps ToolDependencies, ful recordFieldsUsageFor(ctx, deps, "search_code", full, filtered, sentBytes) } -func userOrOrgHandler(ctx context.Context, accountType string, deps ToolDependencies, 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 +func userOrOrgHandler(ctx context.Context, accountType string, deps ToolDependencies, input SearchAccountsInput) (*mcp.CallToolResult, *MinimalSearchUsersResult, error) { + if input.Query == "" { + return utils.NewToolResultError("missing required parameter: query"), nil, nil } + query := input.Query + pagination := repositoryPagination(input.Page, input.PerPage) opts := &github.SearchOptions{ - Sort: sort, - Order: order, + Sort: input.Sort, + Order: input.Order, ListOptions: github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, @@ -436,7 +486,7 @@ func userOrOrgHandler(ctx context.Context, accountType string, deps ToolDependen // User and organization search returns public profile information that is // authored by the account holders themselves, so it is public-untrusted. callResult = attachStaticIFCLabel(ctx, deps, callResult, ifc.PublicUntrusted()) - return callResult, nil, nil + return callResult, minimalResp, nil } // SearchUsers creates a tool to search for GitHub users. @@ -463,7 +513,7 @@ func SearchUsers(t translations.TranslationHelperFunc) inventory.ServerTool { } WithPagination(schema) - return NewTool( + return NewTool[SearchAccountsInput, *MinimalSearchUsersResult]( ToolsetMetadataUsers, mcp.Tool{ Name: "search_users", @@ -475,9 +525,10 @@ func SearchUsers(t translations.TranslationHelperFunc) inventory.ServerTool { InputSchema: schema, }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - return userOrOrgHandler(ctx, "user", deps, args) + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SearchAccountsInput) (*mcp.CallToolResult, *MinimalSearchUsersResult, error) { + return userOrOrgHandler(ctx, "user", deps, input) }, + normalizeSearchArguments(false), ) } @@ -505,7 +556,7 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool { } WithPagination(schema) - return NewTool( + return NewTool[SearchAccountsInput, *MinimalSearchUsersResult]( ToolsetMetadataOrgs, mcp.Tool{ Name: "search_orgs", @@ -517,9 +568,10 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool { InputSchema: schema, }, scopes.RequireAll(scopes.ReadOrg), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - return userOrOrgHandler(ctx, "org", deps, args) + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SearchAccountsInput) (*mcp.CallToolResult, *MinimalSearchUsersResult, error) { + return userOrOrgHandler(ctx, "org", deps, input) }, + normalizeSearchArguments(false), ) } @@ -547,7 +599,7 @@ func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool { } WithPagination(schema) - return NewTool( + return NewTool[SearchCommitsInput, *MinimalSearchCommitsResult]( ToolsetMetadataRepos, mcp.Tool{ Name: "search_commits", @@ -559,27 +611,16 @@ func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool { InputSchema: schema, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SearchCommitsInput) (*mcp.CallToolResult, *MinimalSearchCommitsResult, error) { + if input.Query == "" { + return utils.NewToolResultError("missing required parameter: query"), nil, nil } + query := input.Query + pagination := repositoryPagination(input.Page, input.PerPage) opts := &github.SearchOptions{ - Sort: sort, - Order: order, + Sort: input.Sort, + Order: input.Order, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, @@ -635,7 +676,8 @@ func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool { } } callResult = attachJoinedIFCLabel(ctx, deps, callResult, visibilities, ifc.LabelSearchIssues) - return callResult, nil, nil + return callResult, minimalResult, nil }, + normalizeSearchArguments(false), ) } diff --git a/pkg/github/search_semantic_test.go b/pkg/github/search_semantic_test.go index 0a3bfba5fe..102664d08c 100644 --- a/pkg/github/search_semantic_test.go +++ b/pkg/github/search_semantic_test.go @@ -3,8 +3,8 @@ package github import ( "testing" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index 52e70b639c..a94380876a 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -7,9 +7,9 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -19,7 +19,6 @@ func Test_SearchRepositories(t *testing.T) { // Verify tool definition once serverTool := SearchRepositories(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "search_repositories", tool.Name) assert.NotEmpty(t, tool.Description) @@ -35,24 +34,24 @@ func Test_SearchRepositories(t *testing.T) { // Setup mock search results mockSearchResult := &github.RepositoriesSearchResult{ - Total: github.Ptr(2), - IncompleteResults: github.Ptr(false), + Total: new(2), + IncompleteResults: new(false), Repositories: []*github.Repository{ { - ID: github.Ptr(int64(12345)), - Name: github.Ptr("repo-1"), - FullName: github.Ptr("owner/repo-1"), - HTMLURL: github.Ptr("https://github.com/owner/repo-1"), - Description: github.Ptr("Test repository 1"), - StargazersCount: github.Ptr(100), + ID: new(int64(12345)), + Name: new("repo-1"), + FullName: new("owner/repo-1"), + HTMLURL: new("https://github.com/owner/repo-1"), + Description: new("Test repository 1"), + StargazersCount: new(100), }, { - ID: github.Ptr(int64(67890)), - Name: github.Ptr("repo-2"), - FullName: github.Ptr("owner/repo-2"), - HTMLURL: github.Ptr("https://github.com/owner/repo-2"), - Description: github.Ptr("Test repository 2"), - StargazersCount: github.Ptr(50), + ID: new(int64(67890)), + Name: new("repo-2"), + FullName: new("owner/repo-2"), + HTMLURL: new("https://github.com/owner/repo-2"), + Description: new("Test repository 2"), + StargazersCount: new(50), }, }, } @@ -181,18 +180,18 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { 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)}, + ID: new(int64(1)), + Name: new(r.name), + FullName: new(r.owner + "/" + r.name), + Private: new(r.isPrivate), + Owner: &github.User{Login: new(r.owner)}, } } makeMockClient := func(repos []repoFixture) *http.Client { searchResult := &github.RepositoriesSearchResult{ - Total: github.Ptr(len(repos)), - IncompleteResults: github.Ptr(false), + Total: new(len(repos)), + IncompleteResults: new(false), } for _, r := range repos { searchResult.Repositories = append(searchResult.Repositories, makeRepo(r)) @@ -280,16 +279,16 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { func Test_SearchRepositories_FullOutput(t *testing.T) { mockSearchResult := &github.RepositoriesSearchResult{ - Total: github.Ptr(1), - IncompleteResults: github.Ptr(false), + Total: new(1), + IncompleteResults: new(false), Repositories: []*github.Repository{ { - ID: github.Ptr(int64(12345)), - Name: github.Ptr("test-repo"), - FullName: github.Ptr("owner/test-repo"), - HTMLURL: github.Ptr("https://github.com/owner/test-repo"), - Description: github.Ptr("Test repository"), - StargazersCount: github.Ptr(100), + ID: new(int64(12345)), + Name: new("test-repo"), + FullName: new("owner/test-repo"), + HTMLURL: new("https://github.com/owner/test-repo"), + Description: new("Test repository"), + StargazersCount: new(100), }, }, } @@ -359,30 +358,30 @@ func Test_SearchCode(t *testing.T) { // Setup mock search results mockSearchResult := &github.CodeSearchResult{ - Total: github.Ptr(2), - IncompleteResults: github.Ptr(false), + Total: new(2), + IncompleteResults: new(false), CodeResults: []*github.CodeResult{ { - Name: github.Ptr("file1.go"), - Path: github.Ptr("path/to/file1.go"), - SHA: github.Ptr("abc123def456"), + Name: new("file1.go"), + Path: new("path/to/file1.go"), + SHA: new("abc123def456"), Repository: &github.Repository{ - Name: github.Ptr("repo"), - FullName: github.Ptr("owner/repo"), + Name: new("repo"), + FullName: new("owner/repo"), }, TextMatches: []*github.TextMatch{ { - Fragment: github.Ptr("func main() { fmt.Println(\"hello\") }"), + Fragment: new("func main() { fmt.Println(\"hello\") }"), }, }, }, { - Name: github.Ptr("file2.go"), - Path: github.Ptr("path/to/file2.go"), - SHA: github.Ptr("def456abc123"), + Name: new("file2.go"), + Path: new("path/to/file2.go"), + SHA: new("def456abc123"), Repository: &github.Repository{ - Name: github.Ptr("repo"), - FullName: github.Ptr("owner/repo"), + Name: new("repo"), + FullName: new("owner/repo"), }, }, }, @@ -512,19 +511,19 @@ func Test_SearchCode(t *testing.T) { func Test_SearchCode_FieldFiltering(t *testing.T) { mockSearchResult := &github.CodeSearchResult{ - Total: github.Ptr(1), - IncompleteResults: github.Ptr(false), + Total: new(1), + IncompleteResults: new(false), CodeResults: []*github.CodeResult{ { - Name: github.Ptr("file1.go"), - Path: github.Ptr("path/to/file1.go"), - SHA: github.Ptr("abc123def456"), + Name: new("file1.go"), + Path: new("path/to/file1.go"), + SHA: new("abc123def456"), Repository: &github.Repository{ - Name: github.Ptr("repo"), - FullName: github.Ptr("owner/repo"), + Name: new("repo"), + FullName: new("owner/repo"), }, TextMatches: []*github.TextMatch{ - {Fragment: github.Ptr("func main() {}")}, + {Fragment: new("func main() {}")}, }, }, }, @@ -569,19 +568,19 @@ func Test_SearchCode_FieldFiltering(t *testing.T) { func Test_SearchCode_FieldsTelemetry(t *testing.T) { mockSearchResult := &github.CodeSearchResult{ - Total: github.Ptr(1), - IncompleteResults: github.Ptr(false), + Total: new(1), + IncompleteResults: new(false), CodeResults: []*github.CodeResult{ { - Name: github.Ptr("file1.go"), - Path: github.Ptr("path/to/file1.go"), - SHA: github.Ptr("abc123def456"), + Name: new("file1.go"), + Path: new("path/to/file1.go"), + SHA: new("abc123def456"), Repository: &github.Repository{ - Name: github.Ptr("repo"), - FullName: github.Ptr("owner/repo"), + Name: new("repo"), + FullName: new("owner/repo"), }, TextMatches: []*github.TextMatch{ - {Fragment: github.Ptr("func main() {}")}, + {Fragment: new("func main() {}")}, }, }, }, @@ -640,7 +639,6 @@ func Test_SearchUsers(t *testing.T) { // Verify tool definition once serverTool := SearchUsers(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "search_users", tool.Name) assert.NotEmpty(t, tool.Description) @@ -656,21 +654,21 @@ func Test_SearchUsers(t *testing.T) { // Setup mock search results mockSearchResult := &github.UsersSearchResult{ - Total: github.Ptr(2), - IncompleteResults: github.Ptr(false), + Total: new(2), + IncompleteResults: new(false), Users: []*github.User{ { - Login: github.Ptr("user1"), - ID: github.Ptr(int64(1001)), - HTMLURL: github.Ptr("https://github.com/user1"), - AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1001"), + Login: new("user1"), + ID: new(int64(1001)), + HTMLURL: new("https://github.com/user1"), + AvatarURL: new("https://avatars.githubusercontent.com/u/1001"), }, { - Login: github.Ptr("user2"), - ID: github.Ptr(int64(1002)), - HTMLURL: github.Ptr("https://github.com/user2"), - AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1002"), - Type: github.Ptr("User"), + Login: new("user2"), + ID: new(int64(1002)), + HTMLURL: new("https://github.com/user2"), + AvatarURL: new("https://avatars.githubusercontent.com/u/1002"), + Type: new("User"), }, }, } @@ -827,8 +825,6 @@ func Test_SearchOrgs(t *testing.T) { serverTool := SearchOrgs(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "search_orgs", tool.Name) assert.NotEmpty(t, tool.Description) @@ -843,20 +839,20 @@ func Test_SearchOrgs(t *testing.T) { // Setup mock search results mockSearchResult := &github.UsersSearchResult{ - Total: github.Ptr(int(2)), - IncompleteResults: github.Ptr(false), + Total: new(int(2)), + IncompleteResults: new(false), Users: []*github.User{ { - Login: github.Ptr("org-1"), - ID: github.Ptr(int64(111)), - HTMLURL: github.Ptr("https://github.com/org-1"), - AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/111?v=4"), + Login: new("org-1"), + ID: new(int64(111)), + HTMLURL: new("https://github.com/org-1"), + AvatarURL: new("https://avatars.githubusercontent.com/u/111?v=4"), }, { - Login: github.Ptr("org-2"), - ID: github.Ptr(int64(222)), - HTMLURL: github.Ptr("https://github.com/org-2"), - AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/222?v=4"), + Login: new("org-2"), + ID: new(int64(222)), + HTMLURL: new("https://github.com/org-2"), + AvatarURL: new("https://avatars.githubusercontent.com/u/222?v=4"), }, }, } @@ -985,7 +981,6 @@ 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) @@ -1001,41 +996,41 @@ func Test_SearchCommits(t *testing.T) { now := time.Now().Truncate(time.Second) mockSearchResult := &github.CommitsSearchResult{ - Total: github.Ptr(2), - IncompleteResults: github.Ptr(false), + Total: new(2), + IncompleteResults: new(false), Commits: []*github.CommitResult{ { - SHA: github.Ptr("abc123commit"), - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123commit"), + SHA: new("abc123commit"), + HTMLURL: new("https://github.com/owner/repo/commit/abc123commit"), Commit: &github.Commit{ - Message: github.Ptr("Initial commit"), + Message: new("Initial commit"), Author: &github.CommitAuthor{ - Name: github.Ptr("Author Name"), - Email: github.Ptr("author@example.com"), + Name: new("Author Name"), + Email: new("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"), + Login: new("author"), + ID: new(int64(1)), + HTMLURL: new("https://github.com/author"), }, Repository: &github.Repository{ - FullName: github.Ptr("owner/repo"), - HTMLURL: github.Ptr("https://github.com/owner/repo"), - Private: github.Ptr(false), + FullName: new("owner/repo"), + HTMLURL: new("https://github.com/owner/repo"), + Private: new(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"), + SHA: new("def456commit"), + HTMLURL: new("https://github.com/owner/repo/commit/def456commit"), Commit: &github.Commit{ - Message: github.Ptr("Unlinked author"), + Message: new("Unlinked author"), }, Repository: &github.Repository{ - FullName: github.Ptr("owner/repo"), + FullName: new("owner/repo"), }, }, }, diff --git a/pkg/github/search_tool_types.go b/pkg/github/search_tool_types.go new file mode 100644 index 0000000000..855841186e --- /dev/null +++ b/pkg/github/search_tool_types.go @@ -0,0 +1,157 @@ +package github + +import ( + "encoding/json" + + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" +) + +type SearchAccountsInput struct { + Query string `json:"query"` + Sort string `json:"sort,omitempty"` + Order string `json:"order,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type SearchRepositoriesInput struct { + SearchAccountsInput + MinimalOutput *bool `json:"minimal_output,omitempty"` +} + +type SearchCommitsInput = SearchAccountsInput + +// SearchRepositoriesOutput is the structured repository search result. Minimal +// output reuses MinimalRepository; full output adds a compact set of +// repository details instead of the complete REST API object. +type SearchRepositoriesOutput struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []SearchRepository `json:"items"` +} + +type SearchRepository struct { + MinimalRepository + Homepage string `json:"homepage,omitempty" jsonschema:"Repository homepage URL"` + Visibility string `json:"visibility,omitempty" jsonschema:"Repository visibility"` + PushedAt string `json:"pushed_at,omitempty" jsonschema:"Last push time as an ISO 8601 UTC timestamp"` + WatchersCount *int `json:"watchers_count,omitempty" jsonschema:"Number of watchers"` + Size *int `json:"size,omitempty" jsonschema:"Repository size in kilobytes"` + Disabled *bool `json:"disabled,omitempty" jsonschema:"Whether the repository is disabled"` + IsTemplate *bool `json:"is_template,omitempty" jsonschema:"Whether the repository is a template"` + HasIssues *bool `json:"has_issues,omitempty"` + HasProjects *bool `json:"has_projects,omitempty"` + HasWiki *bool `json:"has_wiki,omitempty"` + HasDiscussions *bool `json:"has_discussions,omitempty"` + License *SearchRepositoryLicense `json:"license,omitempty" jsonschema:"Repository license"` + Permissions *SearchRepositoryPermissions `json:"permissions,omitempty" jsonschema:"Authenticated user's repository permissions"` +} + +type SearchRepositoryLicense struct { + Key string `json:"key,omitempty"` + Name string `json:"name,omitempty"` + SPDXID string `json:"spdx_id,omitempty" jsonschema:"SPDX license identifier"` +} + +type SearchRepositoryPermissions struct { + Admin *bool `json:"admin,omitempty"` + Maintain *bool `json:"maintain,omitempty"` + Push *bool `json:"push,omitempty"` + Triage *bool `json:"triage,omitempty"` + Pull *bool `json:"pull,omitempty"` +} + +var searchRepositoryVisibilityEnum = []any{"public", "private", "internal"} + +func searchRepositoriesOutputSchema() *jsonschema.Schema { + schema, err := jsonschema.For[SearchRepositoriesOutput](nil) + if err != nil { + panic(err) + } + schema.Properties["items"].Items.Properties["visibility"].Enum = searchRepositoryVisibilityEnum + return schema +} + +func minimalSearchRepository(repo *github.Repository) MinimalRepository { + minimalRepo := MinimalRepository{ + ID: repo.GetID(), + Name: repo.GetName(), + FullName: repo.GetFullName(), + Description: repo.GetDescription(), + HTMLURL: repo.GetHTMLURL(), + Language: repo.GetLanguage(), + Stars: repo.GetStargazersCount(), + Forks: repo.GetForksCount(), + OpenIssues: repo.GetOpenIssuesCount(), + Private: repo.GetPrivate(), + Fork: repo.GetFork(), + Archived: repo.GetArchived(), + DefaultBranch: repo.GetDefaultBranch(), + } + if repo.UpdatedAt != nil { + minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z") + } + if repo.CreatedAt != nil { + minimalRepo.CreatedAt = repo.CreatedAt.Format("2006-01-02T15:04:05Z") + } + if repo.Topics != nil { + minimalRepo.Topics = repo.Topics + } + return minimalRepo +} + +func fullSearchRepository(repo *github.Repository) SearchRepository { + out := SearchRepository{ + MinimalRepository: minimalSearchRepository(repo), + Homepage: repo.GetHomepage(), + Visibility: repo.GetVisibility(), + WatchersCount: repo.WatchersCount, + Size: repo.Size, + Disabled: repo.Disabled, + IsTemplate: repo.IsTemplate, + HasIssues: repo.HasIssues, + HasProjects: repo.HasProjects, + HasWiki: repo.HasWiki, + HasDiscussions: repo.HasDiscussions, + } + if repo.PushedAt != nil { + out.PushedAt = repo.PushedAt.Format("2006-01-02T15:04:05Z") + } + if license := repo.License; license != nil { + out.License = &SearchRepositoryLicense{Key: license.GetKey(), Name: license.GetName(), SPDXID: license.GetSPDXID()} + } + if permissions := repo.Permissions; permissions != nil { + out.Permissions = &SearchRepositoryPermissions{ + Admin: permissions.Admin, Maintain: permissions.Maintain, Push: permissions.Push, + Triage: permissions.Triage, Pull: permissions.Pull, + } + } + return out +} + +func normalizeSearchArguments(minimalOutput bool) func(json.RawMessage) (json.RawMessage, error) { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + if _, err := RequiredParam[string](args, "query"); err != nil { + return nil, err + } + for _, field := range []string{"sort", "order"} { + if _, err := OptionalParam[string](args, field); err != nil { + return nil, err + } + } + if _, err := OptionalPaginationParams(args); err != nil { + return nil, err + } + if minimalOutput { + if _, err := OptionalBoolParamWithDefault(args, "minimal_output", true); err != nil { + return nil, err + } + } + return normalizeRepositoryArguments(raw) + } +} diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index 52d735ca0b..b05bf1c21b 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -9,9 +9,9 @@ import ( "regexp" "strings" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v89/github" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -53,6 +53,17 @@ type searchConfig struct { fieldsDeps ToolDependencies } +type SearchIssuesInput struct { + Query string `json:"query"` + Owner string `json:"owner,omitempty"` + Repo string `json:"repo,omitempty"` + Sort string `json:"sort,omitempty"` + Order string `json:"order,omitempty"` + Fields []string `json:"fields,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + type searchOption func(*searchConfig) // withSearchPostProcess registers a callback invoked after a successful search @@ -87,46 +98,31 @@ const ( // 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, targetType string, mode searchMode) (string, *github.SearchOptions, error) { - query, err := RequiredParam[string](args, "query") - if err != nil { - return "", nil, err +func prepareSearchArgs(input SearchIssuesInput, targetType string, mode searchMode) (string, *github.SearchOptions, error) { + query := input.Query + if query == "" { + return "", nil, fmt.Errorf("missing required parameter: query") } if !hasSpecificFilter(query, "is", targetType) { query = fmt.Sprintf("is:%s %s", targetType, query) } - owner, err := OptionalParam[string](args, "owner") - if err != nil { - return "", nil, err + if input.Owner != "" && input.Repo != "" && !hasRepoFilter(query) { + query = fmt.Sprintf("repo:%s/%s %s", input.Owner, input.Repo, query) } - repo, err := OptionalParam[string](args, "repo") - if err != nil { - return "", nil, err + pagination := PaginationParams{Page: 1, PerPage: 30} + if input.Page != nil { + pagination.Page = *input.Page } - - if owner != "" && repo != "" && !hasRepoFilter(query) { - query = fmt.Sprintf("repo:%s/%s %s", owner, repo, query) - } - - sort, err := OptionalParam[string](args, "sort") - if err != nil { - return "", nil, err - } - order, err := OptionalParam[string](args, "order") - if err != nil { - return "", nil, err - } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return "", nil, err + if input.PerPage != nil { + pagination.PerPage = *input.PerPage } opts := &github.SearchOptions{ - Sort: sort, - Order: order, + Sort: input.Sort, + Order: input.Order, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, @@ -135,7 +131,7 @@ func prepareSearchArgs(args map[string]any, targetType string, mode searchMode) // field.: qualifiers require the advanced search API. if strings.Contains(query, "field.") { - opts.AdvancedSearch = github.Ptr(true) + opts.AdvancedSearch = new(true) } // Lexical is the API default, so it leaves search_type unset. @@ -172,36 +168,37 @@ func applySemanticSearch(query string, opts *github.SearchOptions) string { func searchHandler( ctx context.Context, getClient GetClientFn, - args map[string]any, + input SearchIssuesInput, targetType string, errorPrefix string, options ...searchOption, -) (*mcp.CallToolResult, error) { +) (*mcp.CallToolResult, SearchIssuesResponse, error) { + var output SearchIssuesResponse cfg := searchConfig{} for _, opt := range options { opt(&cfg) } - query, opts, err := prepareSearchArgs(args, targetType, searchModeLexical) + query, opts, err := prepareSearchArgs(input, targetType, searchModeLexical) if err != nil { - return utils.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), output, nil } client, err := getClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), nil + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), output, nil } result, resp, err := client.Search.Issues(ctx, query, opts) if err != nil { - return utils.NewToolResultErrorFromErr(errorPrefix, err), nil + return utils.NewToolResultErrorFromErr(errorPrefix, err), output, 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 utils.NewToolResultErrorFromErr(errorPrefix+": failed to read response body", err), output, nil } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), output, nil } // result.Issues are raw *github.Issue objects marshaled directly below rather than through @@ -215,7 +212,7 @@ func searchHandler( if len(cfg.fields) > 0 { filteredItems, err := filterEachField(result.Issues, cfg.fields) if err != nil { - return utils.NewToolResultErrorFromErr(errorPrefix+": failed to filter results", err), nil + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to filter results", err), output, nil } payload = map[string]any{ "total_count": result.Total, @@ -227,7 +224,7 @@ func searchHandler( r, err := json.Marshal(payload) if err != nil { - return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), output, nil } if cfg.fieldsTool != "" { @@ -238,5 +235,13 @@ func searchHandler( if cfg.postProcess != nil { cfg.postProcess(ctx, result, callResult) } - return callResult, nil + output = SearchIssuesResponse{ + Total: result.Total, + IncompleteResults: result.IncompleteResults, + Items: make([]SearchIssueResult, 0, len(result.Issues)), + } + for _, issue := range result.Issues { + output.Items = append(output.Items, SearchIssueResult{Issue: issue}) + } + return callResult, output, nil } diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index 1991d5d2be..43dd1fd005 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -7,19 +7,19 @@ import ( "io" "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/v89/github" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetSecurityAlertInput, *SecretScanningAlertOutput]( ToolsetMetadataSecretProtection, mcp.Tool{ Name: "get_secret_scanning_alert", @@ -48,18 +48,15 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv }, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GetSecurityAlertInput) (*mcp.CallToolResult, *SecretScanningAlertOutput, error) { + if args.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - alertNumber, err := RequiredInt(args, "alertNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.AlertNumber == 0 { + return utils.NewToolResultError("missing required parameter: alertNumber"), nil, nil } client, err := deps.GetClient(ctx) @@ -67,10 +64,10 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - alert, resp, err := client.SecretScanning.GetAlert(ctx, owner, repo, int64(alertNumber)) + alert, resp, err := client.SecretScanning.GetAlert(ctx, args.Owner, args.Repo, int64(args.AlertNumber)) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to get alert with number '%d'", alertNumber), + fmt.Sprintf("failed to get alert with number '%d'", args.AlertNumber), resp, err, ), nil, nil @@ -85,7 +82,7 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get alert", resp, body), nil, nil } - r, err := json.Marshal(alert) + r, err := json.Marshal(alert) //nolint:gosec // G117: This security_events-scoped tool intentionally returns the alert's secret; the result is labeled private-untrusted. if err != nil { return nil, nil, fmt.Errorf("failed to marshal alert: %w", err) } @@ -95,8 +92,9 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv // visibility and surface the matched secret material itself, so the // label is always private-untrusted. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) - return result, nil, nil + return result, secretScanningAlertOutput(alert), nil }, + normalizeSecurityIntegerArguments("alertNumber"), ) } @@ -131,7 +129,7 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se } WithPagination(schema) - return NewTool( + return NewTool[ListSecretScanningAlertsInput, []*SecretScanningAlertOutput]( ToolsetMetadataSecretProtection, mcp.Tool{ Name: "list_secret_scanning_alerts", @@ -143,41 +141,23 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se InputSchema: schema, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - state, err := OptionalParam[string](args, "state") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - secretType, err := OptionalParam[string](args, "secret_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ListSecretScanningAlertsInput) (*mcp.CallToolResult, []*SecretScanningAlertOutput, error) { + if args.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - resolution, err := OptionalParam[string](args, "resolution") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } + pagination := securityPagination(args.Page, args.PerPage) 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, args.Owner, args.Repo, &github.SecretScanningAlertListOptions{ + State: args.State, + SecretType: args.SecretType, + Resolution: args.Resolution, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, @@ -185,7 +165,7 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), + fmt.Sprintf("failed to list alerts for repository '%s/%s'", args.Owner, args.Repo), resp, err, ), nil, nil @@ -200,7 +180,7 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list alerts", resp, body), nil, nil } - r, err := json.Marshal(alerts) + r, err := json.Marshal(alerts) //nolint:gosec // G117: This security_events-scoped tool intentionally returns alert secrets; the result is labeled private-untrusted. if err != nil { return nil, nil, fmt.Errorf("failed to marshal alerts: %w", err) } @@ -210,7 +190,9 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se // visibility and surface the matched secret material itself, so the // label is always private-untrusted. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) - return result, nil, nil + return result, mapSecurityOutputs(alerts, secretScanningAlertOutput), nil }, + normalizeSecurityIntegerArguments("page", "perPage"), + normalizeTypedReadArguments(nil, false), ) } diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go index 603f1e9eb2..4891b142ec 100644 --- a/pkg/github/secret_scanning_test.go +++ b/pkg/github/secret_scanning_test.go @@ -6,9 +6,8 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -17,7 +16,7 @@ import ( func Test_GetSecretScanningAlert(t *testing.T) { toolDef := GetSecretScanningAlert(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + testSecurityToolSnapshot(t, toolDef.Tool) assert.Equal(t, "get_secret_scanning_alert", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) @@ -32,9 +31,9 @@ func Test_GetSecretScanningAlert(t *testing.T) { // Setup mock alert for success case mockAlert := &github.SecretScanningAlert{ - Number: github.Ptr(42), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/42"), + Number: new(42), + State: new("open"), + HTMLURL: new("https://github.com/owner/private-repo/security/secret-scanning/42"), } tests := []struct { @@ -122,7 +121,7 @@ func Test_ListSecretScanningAlerts(t *testing.T) { // Verify tool definition once toolDef := ListSecretScanningAlerts(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + testSecurityToolSnapshot(t, toolDef.Tool) assert.Equal(t, "list_secret_scanning_alerts", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) @@ -139,18 +138,18 @@ func Test_ListSecretScanningAlerts(t *testing.T) { // Setup mock alerts for success case resolvedAlert := github.SecretScanningAlert{ - Number: github.Ptr(2), - HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/2"), - State: github.Ptr("resolved"), - Resolution: github.Ptr("false_positive"), - SecretType: github.Ptr("adafruit_io_key"), + Number: new(2), + HTMLURL: new("https://github.com/owner/private-repo/security/secret-scanning/2"), + State: new("resolved"), + Resolution: new("false_positive"), + SecretType: new("adafruit_io_key"), } openAlert := github.SecretScanningAlert{ - Number: github.Ptr(2), - HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/3"), - State: github.Ptr("open"), - Resolution: github.Ptr("false_positive"), - SecretType: github.Ptr("adafruit_io_key"), + Number: new(2), + HTMLURL: new("https://github.com/owner/private-repo/security/secret-scanning/3"), + State: new("open"), + Resolution: new("false_positive"), + SecretType: new("adafruit_io_key"), } tests := []struct { diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index 63a9a43b0e..42eb505f77 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -7,182 +7,131 @@ import ( "io" "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/v89/github" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( - ToolsetMetadataSecurityAdvisories, - mcp.Tool{ - Name: "list_global_security_advisories", - Description: t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_DESCRIPTION", "List global security advisories from GitHub."), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_USER_TITLE", "List global security advisories"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "ghsaId": { - Type: "string", - Description: "Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", - }, - "type": { - Type: "string", - Description: "Advisory type.", - Enum: []any{"reviewed", "malware", "unreviewed"}, - Default: json.RawMessage(`"reviewed"`), - }, - "cveId": { - Type: "string", - Description: "Filter by CVE ID.", - }, - "ecosystem": { - Type: "string", - Description: "Filter by package ecosystem.", - Enum: []any{"actions", "composer", "erlang", "go", "maven", "npm", "nuget", "other", "pip", "pub", "rubygems", "rust"}, - }, - "severity": { - Type: "string", - Description: "Filter by severity.", - Enum: []any{"unknown", "low", "medium", "high", "critical"}, - }, - "cwes": { - Type: "array", - Description: "Filter by Common Weakness Enumeration IDs (e.g. [\"79\", \"284\", \"22\"]).", - Items: &jsonschema.Schema{ - Type: "string", - }, - }, - "isWithdrawn": { - Type: "boolean", - Description: "Whether to only return withdrawn advisories.", - }, - "affects": { - Type: "string", - Description: "Filter advisories by affected package or version (e.g. \"package1,package2@1.0.0\").", - }, - "published": { - Type: "string", - Description: "Filter by publish date or date range (ISO 8601 date or range).", - }, - "updated": { - Type: "string", - Description: "Filter by update date or date range (ISO 8601 date or range).", - }, - "modified": { - Type: "string", - Description: "Filter by publish or update date or date range (ISO 8601 date or range).", + tool := mcp.Tool{ + Name: "list_global_security_advisories", + Description: t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_DESCRIPTION", "List global security advisories from GitHub."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_USER_TITLE", "List global security advisories"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "ghsaId": { + Type: "string", + Description: "Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", + }, + "type": { + Type: "string", + Description: "Advisory type.", + Enum: []any{"reviewed", "malware", "unreviewed"}, + Default: json.RawMessage(`"reviewed"`), + }, + "cveId": { + Type: "string", + Description: "Filter by CVE ID.", + }, + "ecosystem": { + Type: "string", + Description: "Filter by package ecosystem.", + Enum: []any{"actions", "composer", "erlang", "go", "maven", "npm", "nuget", "other", "pip", "pub", "rubygems", "rust"}, + }, + "severity": { + Type: "string", + Description: "Filter by severity.", + Enum: []any{"unknown", "low", "medium", "high", "critical"}, + }, + "cwes": { + Type: "array", + Description: "Filter by Common Weakness Enumeration IDs (e.g. [\"79\", \"284\", \"22\"]).", + Items: &jsonschema.Schema{ + Type: "string", }, }, + "isWithdrawn": { + Type: "boolean", + Description: "Whether to only return withdrawn advisories.", + }, + "affects": { + Type: "string", + Description: "Filter advisories by affected package or version (e.g. \"package1,package2@1.0.0\").", + }, + "published": { + Type: "string", + Description: "Filter by publish date or date range (ISO 8601 date or range).", + }, + "updated": { + Type: "string", + Description: "Filter by update date or date range (ISO 8601 date or range).", + }, + "modified": { + Type: "string", + Description: "Filter by publish or update date or date range (ISO 8601 date or range).", + }, }, }, + } + return NewToolWithSchemaOptions[ListGlobalSecurityAdvisoriesInput, []*GlobalSecurityAdvisoryOutput]( + ToolsetMetadataSecurityAdvisories, + tool, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + inventory.TypedSchemaOptions{ + ValidationInputSchema: inventory.CloneSchemaWithoutDefaults(tool.InputSchema.(*jsonschema.Schema)), + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ListGlobalSecurityAdvisoriesInput) (*mcp.CallToolResult, []*GlobalSecurityAdvisoryOutput, error) { client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - ghsaID, err := OptionalParam[string](args, "ghsaId") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid ghsaId: %v", err)), nil, nil - } - - typ, err := OptionalParam[string](args, "type") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid type: %v", err)), nil, nil - } - - cveID, err := OptionalParam[string](args, "cveId") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid cveId: %v", err)), nil, nil - } - - eco, err := OptionalParam[string](args, "ecosystem") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid ecosystem: %v", err)), nil, nil - } - - sev, err := OptionalParam[string](args, "severity") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid severity: %v", err)), nil, nil - } - - cwes, err := OptionalStringArrayParam(args, "cwes") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid cwes: %v", err)), nil, nil - } - - isWithdrawn, err := OptionalParam[bool](args, "isWithdrawn") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid isWithdrawn: %v", err)), nil, nil - } - - affects, err := OptionalParam[string](args, "affects") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid affects: %v", err)), nil, nil - } - - published, err := OptionalParam[string](args, "published") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid published: %v", err)), nil, nil - } - - updated, err := OptionalParam[string](args, "updated") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid updated: %v", err)), nil, nil - } - - modified, err := OptionalParam[string](args, "modified") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid modified: %v", err)), nil, nil - } - opts := &github.ListGlobalSecurityAdvisoriesOptions{} - if ghsaID != "" { - opts.GHSAID = &ghsaID + if args.GHSAID != "" { + opts.GHSAID = &args.GHSAID } - if typ != "" { - opts.Type = &typ + if args.Type != "" { + opts.Type = &args.Type } - if cveID != "" { - opts.CVEID = &cveID + if args.CVEID != "" { + opts.CVEID = &args.CVEID } - if eco != "" { - opts.Ecosystem = &eco + if args.Ecosystem != "" { + opts.Ecosystem = &args.Ecosystem } - if sev != "" { - opts.Severity = &sev + if args.Severity != "" { + opts.Severity = &args.Severity } - if len(cwes) > 0 { - opts.CWEs = cwes + if len(args.CWEs) > 0 { + opts.CWEs = args.CWEs } - if isWithdrawn { - opts.IsWithdrawn = &isWithdrawn + if args.IsWithdrawn { + opts.IsWithdrawn = &args.IsWithdrawn } - if affects != "" { - opts.Affects = &affects + if args.Affects != "" { + opts.Affects = &args.Affects } - if published != "" { - opts.Published = &published + if args.Published != "" { + opts.Published = &args.Published } - if updated != "" { - opts.Updated = &updated + if args.Updated != "" { + opts.Updated = &args.Updated } - if modified != "" { - opts.Modified = &modified + if args.Modified != "" { + opts.Modified = &args.Modified } advisories, resp, err := client.SecurityAdvisories.ListGlobalSecurityAdvisories(ctx, opts) @@ -209,13 +158,14 @@ func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) inventor // Database (public) but contain externally authored prose // (untrusted). result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGlobalSecurityAdvisory()) - return result, nil, nil + return result, mapSecurityOutputs(advisories, globalSecurityAdvisoryOutput), nil }, + normalizeGlobalAdvisoryArguments, ) } func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ListRepositorySecurityAdvisoriesInput, []*SecurityAdvisoryOutput]( ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "list_repository_security_advisories", @@ -255,27 +205,12 @@ func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inve }, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ListRepositorySecurityAdvisoriesInput) (*mcp.CallToolResult, []*SecurityAdvisoryOutput, error) { + if args.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - 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 - } - sortField, err := OptionalParam[string](args, "sort") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - state, err := OptionalParam[string](args, "state") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } client, err := deps.GetClient(ctx) @@ -284,17 +219,17 @@ func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inve } opts := &github.ListRepositorySecurityAdvisoriesOptions{} - if direction != "" { - opts.Direction = direction + if args.Direction != "" { + opts.Direction = args.Direction } - if sortField != "" { - opts.Sort = sortField + if args.Sort != "" { + opts.Sort = args.Sort } - if state != "" { - opts.State = state + if args.State != "" { + opts.State = args.State } - advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisories(ctx, owner, repo, opts) + advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisories(ctx, args.Owner, args.Repo, opts) if err != nil { return nil, nil, fmt.Errorf("failed to list repository security advisories: %w", err) } @@ -319,17 +254,17 @@ func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inve // advisories are not world-readable even on a public repo, so the // result is only public when every returned advisory is published. allPublished := allAdvisoriesPublished(advisories) - result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, + result = attachRepoVisibilityIFCLabel(ctx, deps, client, args.Owner, args.Repo, result, func(isPrivate bool) ifc.SecurityLabel { return ifc.LabelRepositorySecurityAdvisory(isPrivate, allPublished) }) - return result, nil, nil + return result, mapSecurityOutputs(advisories, securityAdvisoryOutput), nil }, ) } func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetGlobalSecurityAdvisoryInput, *GlobalSecurityAdvisoryOutput]( ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "get_global_security_advisory", @@ -350,18 +285,17 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S }, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GetGlobalSecurityAdvisoryInput) (*mcp.CallToolResult, *GlobalSecurityAdvisoryOutput, error) { client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - ghsaID, err := RequiredParam[string](args, "ghsaId") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid ghsaId: %v", err)), nil, nil + if args.GHSAID == "" { + return utils.NewToolResultError("invalid ghsaId: missing required parameter: ghsaId"), nil, nil } - advisory, resp, err := client.SecurityAdvisories.GetGlobalSecurityAdvisories(ctx, ghsaID) + advisory, resp, err := client.SecurityAdvisories.GetGlobalSecurityAdvisories(ctx, args.GHSAID) if err != nil { return nil, nil, fmt.Errorf("failed to get advisory: %w", err) } @@ -384,13 +318,13 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S // A global advisory is world-readable (public) but externally // authored (untrusted). result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGlobalSecurityAdvisory()) - return result, nil, nil + return result, globalSecurityAdvisoryOutput(advisory), nil }, ) } func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ListOrgRepositorySecurityAdvisoriesInput, []*SecurityAdvisoryOutput]( ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "list_org_repository_security_advisories", @@ -426,22 +360,9 @@ func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) i }, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - org, err := RequiredParam[string](args, "org") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - direction, err := OptionalParam[string](args, "direction") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - sortField, err := OptionalParam[string](args, "sort") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - state, err := OptionalParam[string](args, "state") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ListOrgRepositorySecurityAdvisoriesInput) (*mcp.CallToolResult, []*SecurityAdvisoryOutput, error) { + if args.Org == "" { + return utils.NewToolResultError("missing required parameter: org"), nil, nil } client, err := deps.GetClient(ctx) @@ -450,17 +371,17 @@ func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) i } opts := &github.ListRepositorySecurityAdvisoriesOptions{} - if direction != "" { - opts.Direction = direction + if args.Direction != "" { + opts.Direction = args.Direction } - if sortField != "" { - opts.Sort = sortField + if args.Sort != "" { + opts.Sort = args.Sort } - if state != "" { - opts.State = state + if args.State != "" { + opts.State = args.State } - advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisoriesForOrg(ctx, org, opts) + advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisoriesForOrg(ctx, args.Org, opts) if err != nil { return nil, nil, fmt.Errorf("failed to list organization repository security advisories: %w", err) } @@ -485,7 +406,7 @@ func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) i // they are conservatively labeled private-untrusted (isPrivate=true, // which forces private regardless of publication state). result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepositorySecurityAdvisory(true, false)) - return result, nil, nil + return result, mapSecurityOutputs(advisories, securityAdvisoryOutput), nil }, ) } diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go index 8c212d5843..a398729acc 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -6,9 +6,8 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" @@ -18,7 +17,7 @@ import ( func Test_ListGlobalSecurityAdvisories(t *testing.T) { toolDef := ListGlobalSecurityAdvisories(translations.NullTranslationHelper) tool := toolDef.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testSecurityToolSnapshot(t, tool) assert.Equal(t, "list_global_security_advisories", tool.Name) assert.NotEmpty(t, tool.Description) @@ -33,10 +32,10 @@ func Test_ListGlobalSecurityAdvisories(t *testing.T) { // Setup mock advisory for success case mockAdvisory := &github.GlobalSecurityAdvisory{ SecurityAdvisory: github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"), - Summary: github.Ptr("Test advisory"), - Description: github.Ptr("This is a test advisory."), - Severity: github.Ptr("high"), + GHSAID: new("GHSA-xxxx-xxxx-xxxx"), + Summary: new("Test advisory"), + Description: new("This is a test advisory."), + Severity: new("high"), }, } @@ -133,7 +132,7 @@ func Test_ListGlobalSecurityAdvisories(t *testing.T) { func Test_GetGlobalSecurityAdvisory(t *testing.T) { toolDef := GetGlobalSecurityAdvisory(translations.NullTranslationHelper) tool := toolDef.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testSecurityToolSnapshot(t, tool) assert.Equal(t, "get_global_security_advisory", tool.Name) assert.NotEmpty(t, tool.Description) @@ -146,10 +145,10 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) { // Setup mock advisory for success case mockAdvisory := &github.GlobalSecurityAdvisory{ SecurityAdvisory: github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"), - Summary: github.Ptr("Test advisory"), - Description: github.Ptr("This is a test advisory."), - Severity: github.Ptr("high"), + GHSAID: new("GHSA-xxxx-xxxx-xxxx"), + Summary: new("Test advisory"), + Description: new("This is a test advisory."), + Severity: new("high"), }, } @@ -240,7 +239,7 @@ func Test_ListRepositorySecurityAdvisories(t *testing.T) { // Verify tool definition once toolDef := ListRepositorySecurityAdvisories(translations.NullTranslationHelper) tool := toolDef.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testSecurityToolSnapshot(t, tool) assert.Equal(t, "list_repository_security_advisories", tool.Name) assert.NotEmpty(t, tool.Description) @@ -256,16 +255,16 @@ func Test_ListRepositorySecurityAdvisories(t *testing.T) { // Setup mock advisories for success cases adv1 := &github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-1111-1111-1111"), - Summary: github.Ptr("Repo advisory one"), - Description: github.Ptr("First repo advisory."), - Severity: github.Ptr("high"), + GHSAID: new("GHSA-1111-1111-1111"), + Summary: new("Repo advisory one"), + Description: new("First repo advisory."), + Severity: new("high"), } adv2 := &github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-2222-2222-2222"), - Summary: github.Ptr("Repo advisory two"), - Description: github.Ptr("Second repo advisory."), - Severity: github.Ptr("medium"), + GHSAID: new("GHSA-2222-2222-2222"), + Summary: new("Repo advisory two"), + Description: new("Second repo advisory."), + Severity: new("medium"), } tests := []struct { @@ -384,14 +383,14 @@ func Test_ListRepositorySecurityAdvisories_IFC_FeatureFlag(t *testing.T) { toolDef := ListRepositorySecurityAdvisories(translations.NullTranslationHelper) publishedAdvisory := &github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-1111-1111-1111"), - Summary: github.Ptr("Published advisory"), - State: github.Ptr("published"), + GHSAID: new("GHSA-1111-1111-1111"), + Summary: new("Published advisory"), + State: new("published"), } draftAdvisory := &github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-2222-2222-2222"), - Summary: github.Ptr("Draft advisory"), - State: github.Ptr("draft"), + GHSAID: new("GHSA-2222-2222-2222"), + Summary: new("Draft advisory"), + State: new("draft"), } makeMockClient := func(isPrivate bool, advisories []*github.SecurityAdvisory) *http.Client { @@ -501,7 +500,7 @@ func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { // Verify tool definition once toolDef := ListOrgRepositorySecurityAdvisories(translations.NullTranslationHelper) tool := toolDef.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testSecurityToolSnapshot(t, tool) assert.Equal(t, "list_org_repository_security_advisories", tool.Name) assert.NotEmpty(t, tool.Description) @@ -515,16 +514,16 @@ func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { assert.ElementsMatch(t, schema.Required, []string{"org"}) adv1 := &github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-aaaa-bbbb-cccc"), - Summary: github.Ptr("Org repo advisory 1"), - Description: github.Ptr("First advisory"), - Severity: github.Ptr("low"), + GHSAID: new("GHSA-aaaa-bbbb-cccc"), + Summary: new("Org repo advisory 1"), + Description: new("First advisory"), + Severity: new("low"), } adv2 := &github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-dddd-eeee-ffff"), - Summary: github.Ptr("Org repo advisory 2"), - Description: github.Ptr("Second advisory"), - Severity: github.Ptr("critical"), + GHSAID: new("GHSA-dddd-eeee-ffff"), + Summary: new("Org repo advisory 2"), + Description: new("Second advisory"), + Severity: new("critical"), } tests := []struct { diff --git a/pkg/github/security_outputs.go b/pkg/github/security_outputs.go new file mode 100644 index 0000000000..88fb34f12b --- /dev/null +++ b/pkg/github/security_outputs.go @@ -0,0 +1,514 @@ +package github + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "strconv" + "time" + + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/google/go-github/v92/github" +) + +type GetCodeQualityFindingInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + FindingNumber int `json:"findingNumber"` +} + +type GetSecurityAlertInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + AlertNumber int `json:"alertNumber"` +} + +type ListCodeScanningAlertsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + State string `json:"state,omitempty"` + Ref string `json:"ref,omitempty"` + Severity string `json:"severity,omitempty"` + ToolName string `json:"tool_name,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type ListSecretScanningAlertsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + State string `json:"state,omitempty"` + SecretType string `json:"secret_type,omitempty"` + Resolution string `json:"resolution,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type ListDependabotAlertsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + State string `json:"state,omitempty"` + Severity string `json:"severity,omitempty"` + PerPage *int `json:"perPage,omitempty"` + After string `json:"after,omitempty"` +} + +type ListGlobalSecurityAdvisoriesInput struct { + GHSAID string `json:"ghsaId,omitempty"` + Type string `json:"type,omitempty"` + CVEID string `json:"cveId,omitempty"` + Ecosystem string `json:"ecosystem,omitempty"` + Severity string `json:"severity,omitempty"` + CWEs []string `json:"cwes,omitempty"` + IsWithdrawn bool `json:"isWithdrawn,omitempty"` + Affects string `json:"affects,omitempty"` + Published string `json:"published,omitempty"` + Updated string `json:"updated,omitempty"` + Modified string `json:"modified,omitempty"` +} + +type ListRepositorySecurityAdvisoriesInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Direction string `json:"direction,omitempty"` + Sort string `json:"sort,omitempty"` + State string `json:"state,omitempty"` +} + +type GetGlobalSecurityAdvisoryInput struct { + GHSAID string `json:"ghsaId"` +} + +type ListOrgRepositorySecurityAdvisoriesInput struct { + Org string `json:"org"` + Direction string `json:"direction,omitempty"` + Sort string `json:"sort,omitempty"` + State string `json:"state,omitempty"` +} + +type CodeQualityRuleOutput struct { + ID *string `json:"id,omitempty"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Help *string `json:"help,omitempty"` + Severity *string `json:"severity,omitempty"` + Category *string `json:"category,omitempty"` +} + +type CodeQualityLocationOutput struct { + Path *string `json:"path,omitempty"` + StartLine *int `json:"start_line,omitempty"` + StartColumn *int `json:"start_column,omitempty"` + EndLine *int `json:"end_line,omitempty"` + EndColumn *int `json:"end_column,omitempty"` +} + +type CodeQualityMessageOutput struct { + Text string `json:"text"` + Markdown string `json:"markdown"` +} + +type CodeQualityFindingOutput struct { + Number *int `json:"number,omitempty"` + State *string `json:"state,omitempty"` + Rule *CodeQualityRuleOutput `json:"rule,omitempty"` + Location *CodeQualityLocationOutput `json:"location,omitempty"` + Message *CodeQualityMessageOutput `json:"message,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty" jsonschema:"Finding creation time (RFC 3339)."` +} + +type CodeScanningRuleOutput struct { + ID *string `json:"id,omitempty"` + Severity *string `json:"severity,omitempty"` + Description *string `json:"description,omitempty"` + Name *string `json:"name,omitempty"` + SecuritySeverityLevel *string `json:"security_severity_level,omitempty" jsonschema:"Security impact level, distinct from rule diagnostic severity."` + Help *string `json:"help,omitempty"` +} + +type CodeScanningLocationOutput struct { + Path *string `json:"path,omitempty"` + StartLine *int `json:"start_line,omitempty"` + EndLine *int `json:"end_line,omitempty"` + StartColumn *int `json:"start_column,omitempty"` + EndColumn *int `json:"end_column,omitempty"` +} + +type CodeScanningInstanceOutput struct { + Ref *string `json:"ref,omitempty"` + State *string `json:"state,omitempty"` + CommitSHA *string `json:"commit_sha,omitempty"` + Message *string `json:"message,omitempty"` + Location *CodeScanningLocationOutput `json:"location,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` +} + +type CodeScanningAlertOutput struct { + Number *int `json:"number,omitempty"` + State *string `json:"state,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + Rule *CodeScanningRuleOutput `json:"rule,omitempty"` + MostRecentInstance *CodeScanningInstanceOutput `json:"most_recent_instance,omitempty"` + DismissedReason *string `json:"dismissed_reason,omitempty"` + DismissedComment *string `json:"dismissed_comment,omitempty"` +} + +type SecretScanningLocationOutput struct { + Path *string `json:"path,omitempty"` + StartLine *int `json:"start_line,omitempty"` + EndLine *int `json:"end_line,omitempty"` + CommitSHA *string `json:"commit_sha,omitempty"` + StartColumn *int `json:"start_column,omitempty"` + EndColumn *int `json:"end_column,omitempty"` +} + +type SecretScanningAlertOutput struct { + Number *int `json:"number,omitempty"` + State *string `json:"state,omitempty"` + Resolution *string `json:"resolution,omitempty"` + SecretType *string `json:"secret_type,omitempty"` + SecretTypeDisplayName *string `json:"secret_type_display_name,omitempty"` + Secret *string `json:"secret,omitempty"` //nolint:gosec // G117: this scoped tool intentionally returns the matched secret. + HTMLURL *string `json:"html_url,omitempty"` + FirstLocationDetected *SecretScanningLocationOutput `json:"first_location_detected,omitempty"` + Validity *string `json:"validity,omitempty" jsonschema:"Credential validity reported by GitHub: active, inactive, or unknown."` + ResolutionComment *string `json:"resolution_comment,omitempty"` + PushProtectionBypassed *bool `json:"push_protection_bypassed,omitempty" jsonschema:"Whether push protection was bypassed for this secret."` + IsBase64Encoded *bool `json:"is_base64_encoded,omitempty" jsonschema:"Whether the detected secret is base64 encoded."` + HasMoreLocations *bool `json:"has_more_locations,omitempty" jsonschema:"Whether additional secret locations exist beyond the first location."` +} + +type SecurityPackageOutput struct { + Ecosystem *string `json:"ecosystem,omitempty"` + Name *string `json:"name,omitempty"` +} + +type SecurityVulnerabilityOutput struct { + Package *SecurityPackageOutput `json:"package,omitempty"` + Severity *string `json:"severity,omitempty"` + VulnerableVersionRange *string `json:"vulnerable_version_range,omitempty"` + FirstPatchedVersion *string `json:"first_patched_version,omitempty" jsonschema:"Earliest version fixing the affected package."` +} + +type DependabotDependencyOutput struct { + Package *SecurityPackageOutput `json:"package,omitempty"` + ManifestPath *string `json:"manifest_path,omitempty"` + Scope *string `json:"scope,omitempty"` +} + +type DependabotAdvisoryOutput struct { + GHSAID *string `json:"ghsa_id,omitempty"` + CVEID *string `json:"cve_id,omitempty"` + Summary *string `json:"summary,omitempty"` + Description *string `json:"description,omitempty"` + Severity *string `json:"severity,omitempty"` + Classification *string `json:"classification,omitempty" jsonschema:"GitHub advisory classification, such as malware or general."` +} + +type DependabotAlertOutput struct { + Number *int `json:"number,omitempty"` + State *string `json:"state,omitempty"` + Dependency *DependabotDependencyOutput `json:"dependency,omitempty"` + SecurityAdvisory *DependabotAdvisoryOutput `json:"security_advisory,omitempty"` + SecurityVulnerability *SecurityVulnerabilityOutput `json:"security_vulnerability,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + DismissedReason *string `json:"dismissed_reason,omitempty"` + DismissedComment *string `json:"dismissed_comment,omitempty"` +} + +type SecurityAdvisoryVulnerabilityOutput struct { + Package *SecurityPackageOutput `json:"package,omitempty"` + Severity *string `json:"severity,omitempty"` + VulnerableVersionRange *string `json:"vulnerable_version_range,omitempty"` + FirstPatchedVersion *string `json:"first_patched_version,omitempty" jsonschema:"Earliest version fixing the affected package."` + PatchedVersions *string `json:"patched_versions,omitempty"` + VulnerableFunctions []string `json:"vulnerable_functions,omitempty"` +} + +type SecurityAdvisoryOutput struct { + GHSAID *string `json:"ghsa_id,omitempty"` + CVEID *string `json:"cve_id,omitempty"` + Summary *string `json:"summary,omitempty"` + Description *string `json:"description,omitempty"` + Severity *string `json:"severity,omitempty"` + State *string `json:"state,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + CWEIDs []string `json:"cwe_ids,omitempty"` + Vulnerabilities []*SecurityAdvisoryVulnerabilityOutput `json:"vulnerabilities,omitempty"` + PublishedAt *time.Time `json:"published_at,omitempty" jsonschema:"Advisory publication time (RFC 3339)."` + UpdatedAt *time.Time `json:"updated_at,omitempty" jsonschema:"Last advisory update time (RFC 3339)."` + WithdrawnAt *time.Time `json:"withdrawn_at,omitempty" jsonschema:"Withdrawal time, when withdrawn (RFC 3339)."` +} + +type GlobalSecurityAdvisoryOutput struct { + SecurityAdvisoryOutput + Type *string `json:"type,omitempty"` + SourceCodeLocation *string `json:"source_code_location,omitempty"` +} + +type SecurityPageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + NextCursor string `json:"nextCursor,omitempty"` + PrevCursor string `json:"prevCursor,omitempty"` +} + +type DependabotAlertsOutput struct { + Alerts []*DependabotAlertOutput `json:"alerts"` + PageInfo SecurityPageInfo `json:"pageInfo"` +} + +func securityPagination(page, perPage *int) PaginationParams { + pagination := PaginationParams{Page: 1, PerPage: 30} + if page != nil { + pagination.Page = *page + } + if perPage != nil { + pagination.PerPage = *perPage + } + return pagination +} + +func securityCursorPagination(perPage *int, after string) CursorPaginationParams { + pagination := CursorPaginationParams{PerPage: 30, After: after} + if perPage != nil { + pagination.PerPage = *perPage + } + return pagination +} + +func normalizeSecurityIntegerArguments(fields ...string) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var arguments map[string]json.RawMessage + if err := json.Unmarshal(raw, &arguments); err != nil { + return nil, err + } + + for _, field := range fields { + value, exists := arguments[field] + if !exists { + continue + } + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return nil, fmt.Errorf("parameter %s is not a valid number", field) + } + number := string(value) + if len(number) > 0 && number[0] == '"' { + if err := json.Unmarshal(value, &number); err != nil { + return nil, fmt.Errorf("parameter %s is not a valid number: %w", field, err) + } + } + parsed, err := strconv.ParseFloat(number, 64) + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || + parsed != math.Trunc(parsed) || parsed > float64(math.MaxInt) || parsed < float64(math.MinInt) { + return nil, fmt.Errorf("parameter %s is not a valid number", field) + } + arguments[field], err = json.Marshal(int(parsed)) + if err != nil { + return nil, err + } + } + if arguments == nil { + return raw, nil + } + return json.Marshal(arguments) + } +} + +func normalizeGlobalAdvisoryArguments(raw json.RawMessage) (json.RawMessage, error) { + var arguments map[string]json.RawMessage + if err := json.Unmarshal(raw, &arguments); err != nil { + return nil, err + } + if value, exists := arguments["cwes"]; exists && bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + delete(arguments, "cwes") + return json.Marshal(arguments) + } + return raw, nil +} + +func mapSecurityOutputs[In, Out any](items []*In, project func(*In) *Out) []*Out { + if items == nil { + return nil + } + outputs := make([]*Out, len(items)) + for i, item := range items { + outputs[i] = project(item) + } + return outputs +} + +func codeScanningAlertOutput(alert *github.Alert) *CodeScanningAlertOutput { + if alert == nil { + return nil + } + output := &CodeScanningAlertOutput{ + Number: alert.Number, + State: alert.State, + HTMLURL: alert.HTMLURL, + DismissedReason: alert.DismissedReason, DismissedComment: alert.DismissedComment, + } + if alert.Rule != nil { + output.Rule = &CodeScanningRuleOutput{ + ID: alert.Rule.ID, Severity: alert.Rule.Severity, + Description: alert.Rule.Description, Name: alert.Rule.Name, + SecuritySeverityLevel: alert.Rule.SecuritySeverityLevel, Help: alert.Rule.Help, + } + } + if instance := alert.MostRecentInstance; instance != nil { + output.MostRecentInstance = &CodeScanningInstanceOutput{ + Ref: instance.Ref, State: instance.State, CommitSHA: instance.CommitSHA, + HTMLURL: instance.HTMLURL, + } + if instance.Message != nil { + output.MostRecentInstance.Message = instance.Message.Text + } + if location := instance.Location; location != nil { + output.MostRecentInstance.Location = &CodeScanningLocationOutput{ + Path: location.Path, StartLine: location.StartLine, EndLine: location.EndLine, + StartColumn: location.StartColumn, EndColumn: location.EndColumn, + } + } + } + return output +} + +func secretScanningAlertOutput(alert *github.SecretScanningAlert) *SecretScanningAlertOutput { + if alert == nil { + return nil + } + output := &SecretScanningAlertOutput{ + Number: alert.Number, State: alert.State, Resolution: alert.Resolution, + SecretType: alert.SecretType, SecretTypeDisplayName: alert.SecretTypeDisplayName, + Secret: alert.Secret, HTMLURL: alert.HTMLURL, + Validity: alert.Validity, ResolutionComment: alert.ResolutionComment, + PushProtectionBypassed: alert.PushProtectionBypassed, + IsBase64Encoded: alert.IsBase64Encoded, HasMoreLocations: alert.HasMoreLocations, + } + if location := alert.FirstLocationDetected; location != nil { + output.FirstLocationDetected = &SecretScanningLocationOutput{ + Path: location.Path, StartLine: location.Startline, EndLine: location.EndLine, + CommitSHA: location.CommitSHA, StartColumn: location.StartColumn, EndColumn: location.EndColumn, + } + } + return output +} + +func securityPackageOutput(pkg *github.VulnerabilityPackage) *SecurityPackageOutput { + if pkg == nil { + return nil + } + return &SecurityPackageOutput{Ecosystem: pkg.Ecosystem, Name: pkg.Name} +} + +func securityVulnerabilityOutput(vulnerability *github.AdvisoryVulnerability) *SecurityVulnerabilityOutput { + if vulnerability == nil { + return nil + } + output := &SecurityVulnerabilityOutput{ + Package: securityPackageOutput(vulnerability.Package), Severity: vulnerability.Severity, + VulnerableVersionRange: vulnerability.VulnerableVersionRange, + } + if patched := vulnerability.FirstPatchedVersion; patched != nil { + output.FirstPatchedVersion = patched.Identifier + } + return output +} + +func dependabotAlertOutput(alert *github.DependabotAlert) *DependabotAlertOutput { + if alert == nil { + return nil + } + output := &DependabotAlertOutput{ + Number: alert.Number, State: alert.State, HTMLURL: alert.HTMLURL, + DismissedReason: alert.DismissedReason, DismissedComment: alert.DismissedComment, + } + if dependency := alert.Dependency; dependency != nil { + output.Dependency = &DependabotDependencyOutput{ + Package: securityPackageOutput(dependency.Package), + ManifestPath: dependency.ManifestPath, Scope: dependency.Scope, + } + } + if advisory := alert.SecurityAdvisory; advisory != nil { + output.SecurityAdvisory = &DependabotAdvisoryOutput{ + GHSAID: advisory.GHSAID, CVEID: advisory.CVEID, Summary: advisory.Summary, + Description: advisory.Description, Severity: advisory.Severity, + Classification: advisory.Classification, + } + } + output.SecurityVulnerability = securityVulnerabilityOutput(alert.SecurityVulnerability) + return output +} + +func securityAdvisoryOutput(advisory *github.SecurityAdvisory) *SecurityAdvisoryOutput { + if advisory == nil { + return nil + } + output := &SecurityAdvisoryOutput{ + GHSAID: advisory.GHSAID, CVEID: advisory.CVEID, Summary: advisory.Summary, + Description: advisory.Description, Severity: advisory.Severity, State: advisory.State, + HTMLURL: advisory.HTMLURL, CWEIDs: advisory.CWEIDs, + PublishedAt: githubTimestampTime(advisory.PublishedAt), + UpdatedAt: githubTimestampTime(advisory.UpdatedAt), + WithdrawnAt: githubTimestampTime(advisory.WithdrawnAt), + } + if advisory.Vulnerabilities != nil { + output.Vulnerabilities = mapSecurityOutputs(advisory.Vulnerabilities, securityAdvisoryVulnerabilityOutput) + } + return output +} + +func securityAdvisoryVulnerabilityOutput(vulnerability *github.AdvisoryVulnerability) *SecurityAdvisoryVulnerabilityOutput { + if vulnerability == nil { + return nil + } + projected := securityVulnerabilityOutput(vulnerability) + return &SecurityAdvisoryVulnerabilityOutput{ + Package: projected.Package, Severity: projected.Severity, + VulnerableVersionRange: projected.VulnerableVersionRange, + FirstPatchedVersion: projected.FirstPatchedVersion, + PatchedVersions: vulnerability.PatchedVersions, + VulnerableFunctions: vulnerability.VulnerableFunctions, + } +} + +func globalSecurityAdvisoryOutput(advisory *github.GlobalSecurityAdvisory) *GlobalSecurityAdvisoryOutput { + if advisory == nil { + return nil + } + output := &GlobalSecurityAdvisoryOutput{ + SecurityAdvisoryOutput: *securityAdvisoryOutput(&advisory.SecurityAdvisory), + Type: advisory.Type, SourceCodeLocation: advisory.SourceCodeLocation, + } + if len(output.CWEIDs) == 0 { + for _, cwe := range advisory.CWEs { + if cwe != nil && cwe.CWEID != nil { + output.CWEIDs = append(output.CWEIDs, *cwe.CWEID) + } + } + } + if advisory.Vulnerabilities != nil { + output.Vulnerabilities = mapSecurityOutputs(advisory.Vulnerabilities, globalAdvisoryVulnerabilityOutput) + } + return output +} + +func globalAdvisoryVulnerabilityOutput(vulnerability *github.GlobalSecurityVulnerability) *SecurityAdvisoryVulnerabilityOutput { + if vulnerability == nil { + return nil + } + return &SecurityAdvisoryVulnerabilityOutput{ + Package: securityPackageOutput(vulnerability.Package), + FirstPatchedVersion: vulnerability.FirstPatchedVersion, + VulnerableVersionRange: vulnerability.VulnerableVersionRange, + VulnerableFunctions: vulnerability.VulnerableFunctions, + } +} + +func githubTimestampTime(timestamp *github.Timestamp) *time.Time { + if timestamp == nil { + return nil + } + return ×tamp.Time +} diff --git a/pkg/github/server.go b/pkg/github/server.go index b8f0197889..de82c8be3f 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -8,11 +8,11 @@ import ( "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" + gherrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/octicons" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -35,7 +35,7 @@ type MCPServerConfig struct { EnabledTools []string // EnabledFeatures is a list of feature flags that are enabled - // Items with FeatureFlagEnable matching an entry in this list will be available + // Tool feature rules evaluate entries in this list. EnabledFeatures []string // ReadOnly indicates if we should only offer read-only tools @@ -110,12 +110,6 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci ghServer := NewServer(cfg.Version, cfg.Translator("SERVER_NAME", "github-mcp-server"), cfg.Translator("SERVER_TITLE", "GitHub MCP Server"), serverOpts) - // Add middlewares. Order matters - for example, the error context middleware should be applied last so that it runs FIRST (closest to the handler) to ensure all errors are captured, - // 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) - if unrecognized := inv.UnrecognizedToolsets(); len(unrecognized) > 0 { cfg.Logger.Warn("Warning: unrecognized toolsets ignored", "toolsets", strings.Join(unrecognized, ", ")) } @@ -123,11 +117,20 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci // Register GitHub tools/resources/prompts from the inventory. inv.RegisterAll(ctx, ghServer, deps, cfg.ToolHandlerMiddleware...) + // Add request middleware after registering tools so it wraps the protocol + // adapter. Preflight and legacy typed calls then receive the same request + // context as modern typed calls. + // Middleware order matters: the error context wrapper is applied last so it + // runs first and can observe errors from every inner middleware. + ghServer.AddReceivingMiddleware(middleware...) + ghServer.AddReceivingMiddleware(injectFeatureStateMiddleware(inv)) + ghServer.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext) + // 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. + // available. The resources are static HTML referenced by tools' _meta.ui + // block (the inventory strips that block for clients that do not support + // MCP Apps), 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. @@ -138,6 +141,14 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci return ghServer, nil } +func injectFeatureStateMiddleware(inv *inventory.Inventory) mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + return next(inv.WithFeatureState(ctx), method, req) + } + } +} + // 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 { diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 07cb63c85f..8fe76965b0 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -11,13 +11,13 @@ import ( "testing" "time" - "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/translations" - gogithub "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/lockdown" + "github.com/github/github-mcp-server/v2/pkg/observability" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/raw" + "github.com/github/github-mcp-server/v2/pkg/translations" + gogithub "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -62,10 +62,12 @@ func (s stubDeps) GetRawClient(ctx context.Context) (*raw.Client, error) { 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) 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() } @@ -121,7 +123,7 @@ func mockRESTPermissionServer(t *testing.T, defaultPerm string, overrides map[st } } resp := gogithub.RepositoryPermissionLevel{ - Permission: gogithub.Ptr(perm), + Permission: new(perm), } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) @@ -192,6 +194,90 @@ func TestNewMCPServer_CreatesSuccessfully(t *testing.T) { // is already tested in pkg/github/*_test.go. } +func TestNewMCPServerTypedLegacyPreflightAndHandlerReceiveDependencies(t *testing.T) { + type output struct { + OK bool `json:"ok"` + } + deps := stubDeps{obsv: stubExporters()} + tool := NewToolWithSchemaOptions[struct{}, output]( + inventory.ToolsetMetadata{ID: inventory.ToolsetID("test"), Description: "Test"}, + mcp.Tool{Name: "typed_dependency_tool"}, + inventory.ScopeAccess{}, + inventory.TypedSchemaOptions{ + Preflight: func(ctx context.Context, _ *mcp.CallToolRequest) (context.Context, *mcp.CallToolResult, error) { + if _, ok := DepsFromContext(ctx); !ok { + return ctx, nil, errors.New("dependencies missing in preflight") + } + return ctx, nil, nil + }, + }, + func(ctx context.Context, handlerDeps ToolDependencies, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, output, error) { + if _, ok := DepsFromContext(ctx); !ok || handlerDeps == nil { + return nil, output{}, errors.New("dependencies missing in handler") + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "legacy text"}}, + }, output{OK: true}, nil + }, + ) + inv, err := inventory.NewBuilder(). + SetTools([]inventory.ServerTool{tool}). + WithToolsets([]string{"all"}). + Build() + require.NoError(t, err) + cfg := MCPServerConfig{ + Version: "test", + Translator: translations.NullTranslationHelper, + Logger: slog.New(slog.DiscardHandler), + } + server, err := NewMCPServer(context.Background(), &cfg, deps, inv) + require.NoError(t, err) + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "legacy-test", Version: "1"}, nil) + clientSession, err := client.Connect(context.Background(), clientTransport, &mcp.ClientSessionOptions{ + ProtocolVersion: "2025-11-25", + }) + require.NoError(t, err) + t.Cleanup(func() { _ = clientSession.Close() }) + + list, err := clientSession.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.Nil(t, list.Tools[0].OutputSchema) + + result, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_dependency_tool"}) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1) + assert.Equal(t, "legacy text", result.Content[0].(*mcp.TextContent).Text) +} + +func TestFeatureStateMiddlewareCachesHandlerChecks(t *testing.T) { + var calls int + checker := func(_ context.Context, flag string) (bool, error) { + calls++ + return flag == "enabled", nil + } + inv, err := NewInventory(translations.NullTranslationHelper). + WithFeatureChecker(checker). + Build() + require.NoError(t, err) + + next := func(ctx context.Context, _ string, _ mcp.Request) (mcp.Result, error) { + assert.True(t, inventory.ResolveFeature(ctx, nil, "enabled")) + assert.True(t, inventory.ResolveFeature(ctx, nil, "enabled")) + return nil, nil + } + _, err = injectFeatureStateMiddleware(inv)(next)(context.Background(), "tools/call", nil) + require.NoError(t, err) + assert.Equal(t, 1, calls) +} + // advertisedServerCapabilities connects an in-memory client to the given server // and returns the capabilities the server advertised during initialization. func advertisedServerCapabilities(t *testing.T, server *mcp.Server) *mcp.ServerCapabilities { diff --git a/pkg/github/tool_scopes.go b/pkg/github/tool_scopes.go index c61dbd3829..02b01c7881 100644 --- a/pkg/github/tool_scopes.go +++ b/pkg/github/tool_scopes.go @@ -1,10 +1,18 @@ package github import ( - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" ) +func publicRepositoryWriteScopeAccess() inventory.ScopeAccess { + access := scopes.RequireAll(scopes.Repo) + access.Visible = func(activeScopes []string) bool { + return scopes.HasAll(activeScopes, scopes.PublicRepo) + } + return access +} + func repositoryOrOrganizationScopeAccess() inventory.ScopeAccess { return scopes.DynamicChallenge( []scopes.Scope{scopes.Repo, scopes.ReadOrg}, diff --git a/pkg/github/tool_scopes_test.go b/pkg/github/tool_scopes_test.go index 6854237f56..64b490854d 100644 --- a/pkg/github/tool_scopes_test.go +++ b/pkg/github/tool_scopes_test.go @@ -3,8 +3,8 @@ package github import ( "testing" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index f9b51159b5..53503f68fe 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -5,12 +5,12 @@ import ( "slices" "strings" - "github.com/google/go-github/v89/github" + "github.com/google/go-github/v92/github" "github.com/shurcooL/githubv4" - "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/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" ) type GetClientFn func(context.Context) (*github.Client, error) @@ -73,6 +73,11 @@ var ( Description: "GitHub Organization related tools", Icon: "organization", } + ToolsetMetadataGovernance = inventory.ToolsetMetadata{ + ID: "governance", + Description: "Repository governance tools for managing rulesets and custom properties at the repository, organization, and enterprise levels", + Icon: "law", + } ToolsetMetadataActions = inventory.ToolsetMetadata{ ID: "actions", Description: "GitHub Actions workflows and CI/CD operations", @@ -159,9 +164,9 @@ var ( 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. +// HeaderAllowedFeatureFlags returns the feature flags that clients may enable +// through the X-MCP-Features header or features URL query parameter. It +// delegates to AllowedFeatureFlags as the single source of truth. func HeaderAllowedFeatureFlags() []string { return slices.Clone(AllowedFeatureFlags) } @@ -210,6 +215,7 @@ func newToolConfig(opts []ToolOption) toolConfig { // AllTools returns all tools with their embedded toolset metadata. // Tool functions return ServerTool directly with toolset info. func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []inventory.ServerTool { + cfg := newToolConfig(opts) return withCSVOutput([]inventory.ServerTool{ // Context tools GetMe(t), @@ -253,6 +259,7 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent ListIssueFields(t), IssueWrite(t), AddIssueComment(t), + UpdateIssueComment(t), SubIssueWrite(t), IssueDependencyRead(t), IssueDependencyWrite(t), @@ -264,6 +271,12 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent // Organization tools SearchOrgs(t), + // Governance tools + RepositoryRulesetRead(t), + CreateRepositoryRuleset(t), + CustomPropertiesRead(t), + CustomPropertiesWrite(t), + // Pull request tools PullRequestRead(t), ListPullRequests(t), @@ -272,7 +285,8 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent UpdatePullRequestBranch(t), CreatePullRequest(t), UpdatePullRequest(t), - PullRequestReviewWrite(t), + pullRequestReviewWrite(t, false, cfg), + PullRequestReviewWriteWithResolutionReason(t, opts...), AddCommentToPendingReview(t), AddReplyToPullRequestComment(t), @@ -359,7 +373,11 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent GranularReprioritizeSubIssue(t), GranularSetIssueFields(t), GranularAddIssueReaction(t), + GranularRemoveIssueReaction(t), GranularAddIssueCommentReaction(t), + GranularRemoveIssueCommentReaction(t), + GranularHideIssueComment(t), + GranularUnhideIssueComment(t), // Granular pull request tools (feature-flagged, replace consolidated update_pull_request/pull_request_review_write) GranularUpdatePullRequestTitle(t), @@ -371,9 +389,15 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent GranularSubmitPendingPullRequestReview(t), GranularDeletePendingPullRequestReview(t), GranularAddPullRequestReviewComment(t), - GranularResolveReviewThread(t), + granularResolveReviewThread(t, false, cfg), + GranularResolveReviewThreadWithResolutionReason(t, opts...), GranularUnresolveReviewThread(t), GranularAddPullRequestReviewCommentReaction(t), + GranularRemovePullRequestReviewCommentReaction(t), + GranularHidePullRequestReviewComment(t), + GranularUnhidePullRequestReviewComment(t), + GranularHidePullRequestReview(t), + GranularUnhidePullRequestReview(t), }) } diff --git a/pkg/github/tools_static_validation_test.go b/pkg/github/tools_static_validation_test.go index 34cd309d6a..6f8914c212 100644 --- a/pkg/github/tools_static_validation_test.go +++ b/pkg/github/tools_static_validation_test.go @@ -4,7 +4,7 @@ import ( "os" "testing" - "github.com/github/github-mcp-server/pkg/toolvalidation" + "github.com/github/github-mcp-server/v2/pkg/toolvalidation" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/tools_validation_test.go b/pkg/github/tools_validation_test.go index dac04b5d54..fd34eec246 100644 --- a/pkg/github/tools_validation_test.go +++ b/pkg/github/tools_validation_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -71,6 +71,46 @@ func TestAllToolInputSchemasAvoidTopLevelCombinators(t *testing.T) { } } +// TestAllToolInputSchemasUseCanonicalPaginationNames keeps pagination properties +// spelled one way across the whole inventory. The pagination helpers read page, +// perPage, after and before, so a schema that advertises a case or underscore +// variant of one of those names promises a knob the handler never turns: whatever +// the client sends is dropped and the default is used instead. actions_list +// advertised per_page for months that way. +func TestAllToolInputSchemasUseCanonicalPaginationNames(t *testing.T) { + canonical := map[string]string{ + "page": "page", + "perpage": "perPage", + "after": "after", + "before": "before", + } + + tools := AllTools(stubTranslation) + require.NotEmpty(t, tools, "AllTools should return at least one tool") + + for _, serverTool := range tools { + tool := serverTool.Tool + t.Run(tool.Name, func(t *testing.T) { + data, err := json.Marshal(tool.InputSchema) + require.NoError(t, err, "Tool %q InputSchema must marshal", tool.Name) + + var schema struct { + Properties map[string]json.RawMessage `json:"properties"` + } + require.NoError(t, json.Unmarshal(data, &schema), "Tool %q InputSchema must be a JSON object", tool.Name) + + for name := range schema.Properties { + want, ok := canonical[strings.ToLower(strings.ReplaceAll(name, "_", ""))] + if !ok { + continue + } + assert.Equal(t, want, name, + "Tool %q advertises pagination property %q; the canonical spelling is %q", tool.Name, name, want) + } + }) + } +} + // TestAllResourcesHaveRequiredMetadata validates that all resources have mandatory metadata func TestAllResourcesHaveRequiredMetadata(t *testing.T) { // Resources are now stateless - no client functions needed @@ -126,34 +166,72 @@ func TestToolReadOnlyHintConsistency(t *testing.T) { } } -// TestNoDuplicateToolNames ensures all tools have unique names +// TestNoDuplicateToolNames ensures duplicate names cannot be enabled together. func TestNoDuplicateToolNames(t *testing.T) { tools := AllTools(stubTranslation) - seen := make(map[string]bool) - featureFlagged := make(map[string]bool) + toolsByName := make(map[string][]inventory.ServerTool) + for _, tool := range tools { + toolsByName[tool.Tool.Name] = append(toolsByName[tool.Tool.Name], tool) + } // get_label is intentionally in both issues and labels toolsets for conformance // with original behavior where it was registered in both - allowedDuplicates := map[string]bool{ - "get_label": true, + for name, variants := range toolsByName { + if name == "get_label" || len(variants) < 2 { + continue + } + assert.False(t, featureDeclarationsOverlap(variants), "tool variants for %q can be enabled together", name) } +} - // First pass: identify tools that have feature flags (mutually exclusive at runtime) - for _, tool := range tools { - if tool.FeatureFlagEnable != "" || len(tool.FeatureFlagDisable) > 0 { - featureFlagged[tool.Tool.Name] = true +func featureDeclarationsOverlap(variants []inventory.ServerTool) bool { + positions := make(map[inventory.FeatureFlag]uint) + for _, variant := range variants { + for _, feature := range variant.FeatureRule.Features() { + if _, ok := positions[feature]; !ok { + positions[feature] = uint(len(positions)) + } } } + if len(positions) > 16 { + return true + } - for _, tool := range tools { - name := tool.Tool.Name - // Allow duplicates for explicitly allowed tools and feature-flagged tools - if !allowedDuplicates[name] && !featureFlagged[name] { - assert.False(t, seen[name], - "Duplicate tool name found: %q", name) + for assignment := range 1 << len(positions) { + enabled := 0 + featureAsBool := func(feature inventory.FeatureFlag) bool { + return assignment&(1< 1 { + return true } - seen[name] = true } + return false +} + +func TestFeatureRulesOverlap(t *testing.T) { + flag := inventory.FeatureFlag("flag") + enabled := inventory.ServerTool{FeatureRule: inventory.NewFeatureRule([]inventory.FeatureFlag{flag}, func(featureAsBool inventory.FeatureResolver) bool { + return featureAsBool(flag) + })} + disabled := inventory.ServerTool{FeatureRule: inventory.NewFeatureRule([]inventory.FeatureFlag{flag}, func(featureAsBool inventory.FeatureResolver) bool { + return !featureAsBool(flag) + })} + otherFlag := inventory.FeatureFlag("other") + otherEnabled := inventory.ServerTool{FeatureRule: inventory.NewFeatureRule([]inventory.FeatureFlag{otherFlag}, func(featureAsBool inventory.FeatureResolver) bool { + return featureAsBool(otherFlag) + })} + ungated := inventory.ServerTool{} + + assert.True(t, featureDeclarationsOverlap([]inventory.ServerTool{enabled, enabled})) + assert.True(t, featureDeclarationsOverlap([]inventory.ServerTool{enabled, otherEnabled})) + assert.True(t, featureDeclarationsOverlap([]inventory.ServerTool{ungated, enabled})) + assert.False(t, featureDeclarationsOverlap([]inventory.ServerTool{enabled, disabled})) } // TestNoDuplicateResourceNames ensures all resources have unique names diff --git a/pkg/github/toolset_icons_test.go b/pkg/github/toolset_icons_test.go index 7cfe4bef77..53a6aa3380 100644 --- a/pkg/github/toolset_icons_test.go +++ b/pkg/github/toolset_icons_test.go @@ -3,7 +3,7 @@ package github import ( "testing" - "github.com/github/github-mcp-server/pkg/octicons" + "github.com/github/github-mcp-server/v2/pkg/octicons" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/toolset_instructions.go b/pkg/github/toolset_instructions.go index 3b3a54eadd..b282d42806 100644 --- a/pkg/github/toolset_instructions.go +++ b/pkg/github/toolset_instructions.go @@ -1,6 +1,6 @@ package github -import "github.com/github/github-mcp-server/pkg/inventory" +import "github.com/github/github-mcp-server/v2/pkg/inventory" // Toolset instruction functions - these generate context-aware instructions for each toolset. // They are called during inventory build to generate server instructions. diff --git a/pkg/github/typed_account_repository_search_test.go b/pkg/github/typed_account_repository_search_test.go new file mode 100644 index 0000000000..64d32b2973 --- /dev/null +++ b/pkg/github/typed_account_repository_search_test.go @@ -0,0 +1,337 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "maps" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func typedAccountRepositorySearchTools() []inventory.ServerTool { + return []inventory.ServerTool{ + SearchRepositories(translations.NullTranslationHelper), + SearchUsers(translations.NullTranslationHelper), + SearchOrgs(translations.NullTranslationHelper), + SearchCommits(translations.NullTranslationHelper), + } +} + +func connectTypedSearchClient(t *testing.T, tool inventory.ServerTool, deps ToolDependencies, protocol string) *mcp.ClientSession { + t.Helper() + server := mcp.NewServer(&mcp.Implementation{Name: "typed-search-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + tool.RegisterFunc(server, deps) + version := protocol + if protocol == "" { + version = inventory.ProtocolVersionMultiRoundTrip + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch request := req.(type) { + case *mcp.ListToolsRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + case *mcp.CallToolRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + } + return next(ctx, method, req) + } + }) + } + return connectCommentVisibilityClient(t, server, version) +} + +func TestTypedAccountRepositorySearchOutputs(t *testing.T) { + fixtures := map[string]struct { + path string + body string + text string + }{ + "search_repositories": { + GetSearchRepositories, + `{"total_count":1,"incomplete_results":false,"items":[{"id":7,"name":"repo","full_name":"owner/repo","description":"description","html_url":"repo-url","stargazers_count":0,"private":false,"fork":false,"archived":false,"created_at":"2026-01-02T03:04:05Z","topics":["go"],"owner":{"login":"owner"},"node_id":"R_7","homepage":"https://example.com","visibility":"public","pushed_at":"2026-01-03T03:04:05Z","watchers_count":4,"size":0,"disabled":false,"has_issues":true,"license":{"key":"mit","name":"MIT License","spdx_id":"MIT","url":"license-api-url"},"permissions":{"admin":false,"pull":true},"issues_url":"issues-api-url","parent":{"id":8},"custom_properties":{"text":"value"}}]}`, + `{"total_count":1,"incomplete_results":false,"items":[{"id":7,"name":"repo","full_name":"owner/repo","description":"\u003cb\u003edescription\u003c/b\u003e","html_url":"repo-url","stargazers_count":0,"forks_count":0,"open_issues_count":0,"created_at":"2026-01-02T03:04:05Z","topics":["go"],"private":false,"fork":false,"archived":false}]}`, + }, + "search_users": { + GetSearchUsers, + `{"total_count":3,"incomplete_results":true,"items":[{"id":7,"login":"octo","html_url":"profile","avatar_url":"avatar"},{"id":8},{"id":9,"login":""}]}`, + `{"total_count":3,"incomplete_results":true,"items":[{"login":"octo","id":7,"profile_url":"profile","avatar_url":"avatar"},{"login":"","id":9}]}`, + }, + "search_orgs": { + GetSearchUsers, + `{"total_count":3,"incomplete_results":true,"items":[{"id":7,"login":"octo","html_url":"profile","avatar_url":"avatar"},{"id":8},{"id":9,"login":""}]}`, + `{"total_count":3,"incomplete_results":true,"items":[{"login":"octo","id":7,"profile_url":"profile","avatar_url":"avatar"},{"login":"","id":9}]}`, + }, + "search_commits": { + GetSearchCommits, + `{"total_count":2,"incomplete_results":false,"items":[{"sha":"abc","html_url":"commit-url","commit":{"message":"fix","author":{"name":"Author","email":"a@example.com","date":"2026-01-02T03:04:05Z"}},"author":{"login":"octo","id":7,"html_url":"profile","avatar_url":"avatar"},"repository":{"full_name":"owner/repo","html_url":"repo-url","private":true}},{"sha":"def","html_url":""}]}`, + `{"total_count":2,"incomplete_results":false,"items":[{"sha":"abc","html_url":"commit-url","commit":{"message":"\u003cb\u003efix\u003c/b\u003e","author":{"name":"Author","email":"a@example.com","date":"2026-01-02T03:04:05Z"}},"author":{"login":"octo","id":7,"profile_url":"profile","avatar_url":"avatar"},"repository":{"full_name":"owner/repo","html_url":"repo-url","private":true}},{"sha":"def","html_url":""}]}`, + }, + } + for _, tool := range typedAccountRepositorySearchTools() { + fixture := fixtures[tool.Tool.Name] + var apiErrorText string + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip, ""} { + t.Run(tool.Tool.Name+"/"+protocol, func(t *testing.T) { + body := fixture.body + query := "repo:owner/repo fix" + page, perPage, sort, order := "1", "30", "", "" + calls := 0 + handler := func(w http.ResponseWriter, r *http.Request) { + calls++ + expectedQuery := query + if !hasTypeFilter(query) { + switch tool.Tool.Name { + case "search_users": + expectedQuery = "type:user " + query + case "search_orgs": + expectedQuery = "type:org " + query + } + } + assert.Equal(t, expectedQuery, r.URL.Query().Get("q")) + assert.Equal(t, page, r.URL.Query().Get("page")) + assert.Equal(t, perPage, r.URL.Query().Get("per_page")) + assert.Equal(t, sort, r.URL.Query().Get("sort")) + assert.Equal(t, order, r.URL.Query().Get("order")) + if body == "forbidden" { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + return + } + _, _ = w.Write([]byte(body)) + } + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{fixture.path: handler})), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + session := connectTypedSearchClient(t, tool, deps, protocol) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + listed := list.Tools[0] + assert.Equal(t, tool.Tool.Name, listed.Name) + var resolved *jsonschema.Resolved + if protocol == inventory.ProtocolVersionMultiRoundTrip { + require.NotNil(t, listed.OutputSchema) + canonical := *listed + canonical.Icons = nil // Registration adds toolset icons absent from canonical snapshots. + require.NoError(t, toolsnaps.Test(tool.Tool.Name, canonical)) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, listed.OutputSchema)), &schema)) + resolved, err = schema.Resolve(nil) + require.NoError(t, err) + } else { + assert.Nil(t, listed.OutputSchema) + } + checkStructured := func(args map[string]any, expected, structured string) { + t.Helper() + args = maps.Clone(args) + args["query"] = query + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, "%+v", result) + require.Len(t, result.Content, 1) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + assert.JSONEq(t, structured, getTextResult(t, result).Text) + } else { + assert.Equal(t, expected, getTextResult(t, result).Text) + } + require.NotNil(t, result.Meta) + label := unmarshalIFC(t, result.Meta["ifc"]) + if tool.Tool.Name == "search_commits" && body == fixture.body { + assert.Equal(t, "trusted", label["integrity"]) + assert.Equal(t, "private", label["confidentiality"]) + } else { + assert.Equal(t, "untrusted", label["integrity"]) + assert.Equal(t, "public", label["confidentiality"]) + } + if resolved == nil { + assert.Nil(t, result.StructuredContent) + } else { + require.NotNil(t, result.StructuredContent) + encoded := mustMarshalJSON(t, result.StructuredContent) + assert.JSONEq(t, structured, encoded) + assert.JSONEq(t, encoded, getTextResult(t, result).Text) + var output any + require.NoError(t, json.Unmarshal([]byte(encoded), &output)) + require.NoError(t, resolved.Validate(output)) + } + request := createMCPRequest(args) + direct, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, direct.IsError) + assert.Equal(t, expected, getTextResult(t, direct).Text) + } + check := func(args map[string]any, expected string) { + t.Helper() + checkStructured(args, expected, expected) + } + for _, args := range []map[string]any{ + {}, {"page": 0, "perPage": 0}, {"page": "0", "perPage": "0"}, + {"page": "0e0", "perPage": "-0"}, {"fields": []string{"id"}}, + } { + check(args, fixture.text) + } + page, perPage, order = "2", "7", "asc" + switch tool.Tool.Name { + case "search_repositories": + sort = "stars" + case "search_commits": + sort = "author-date" + default: + sort = "followers" + } + check(map[string]any{"page": "2e0", "perPage": "7.0", "sort": sort, "order": order}, fixture.text) + page, perPage, sort, order = "1", "30", "", "" + query = "type:org (location:seattle OR location:california)" + check(map[string]any{}, fixture.text) + if tool.Tool.Name == "search_repositories" { + var full github.RepositoriesSearchResult + require.NoError(t, json.Unmarshal([]byte(body), &full)) + checkStructured(map[string]any{"minimal_output": false}, mustMarshalJSON(t, full), + `{"total_count":1,"incomplete_results":false,"items":[{"id":7,"name":"repo","full_name":"owner/repo","description":"description","html_url":"repo-url","stargazers_count":0,"forks_count":0,"open_issues_count":0,"created_at":"2026-01-02T03:04:05Z","topics":["go"],"private":false,"fork":false,"archived":false,"homepage":"https://example.com","visibility":"public","pushed_at":"2026-01-03T03:04:05Z","watchers_count":4,"size":0,"disabled":false,"has_issues":true,"license":{"key":"mit","name":"MIT License","spdx_id":"MIT"},"permissions":{"admin":false,"pull":true}}]}`) + check(map[string]any{"minimal_output": true}, fixture.text) + } + for _, emptyBody := range []string{`{"total_count":0,"incomplete_results":false,"items":[]}`, `{"items":null}`, `{}`} { + body = emptyBody + check(map[string]any{}, `{"total_count":0,"incomplete_results":false,"items":[]}`) + if tool.Tool.Name == "search_repositories" { + expected := `{"total_count":0,"incomplete_results":false}` + if emptyBody != `{"total_count":0,"incomplete_results":false,"items":[]}` { + expected = "{}" + } + checkStructured(map[string]any{"minimal_output": false}, expected, `{"total_count":0,"incomplete_results":false,"items":[]}`) + } + } + before := calls + invalid := []struct { + args map[string]any + text string + }{ + {map[string]any{}, "missing required parameter: query"}, + {map[string]any{"query": ""}, "missing required parameter: query"}, + {map[string]any{"query": nil}, "parameter query is not of type string"}, + {map[string]any{"query": 1}, "parameter query is not of type string"}, + {map[string]any{"query": query, "sort": nil}, "parameter sort is not of type string"}, + {map[string]any{"query": query, "order": false}, "parameter order is not of type string"}, + {map[string]any{"query": query, "page": "1.5"}, "non-integer numeric value"}, + {map[string]any{"query": query, "page": 1.5}, "non-integer numeric value"}, + {map[string]any{"query": query, "perPage": false}, "expected number"}, + {map[string]any{"query": query, "perPage": nil}, "expected number"}, + {map[string]any{"query": query, "page": "NaN"}, "non-finite numeric value"}, + {map[string]any{"query": query, "page": "1e30"}, "numeric value out of int range"}, + {map[string]any{"query": query, "after": nil}, "parameter after is not of type string"}, + } + if tool.Tool.Name == "search_repositories" { + invalid = append(invalid, struct { + args map[string]any + text string + }{map[string]any{"query": query, "minimal_output": nil}, "parameter minimal_output is not of type bool"}) + } + for _, tc := range invalid { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.text) + assert.Nil(t, result.StructuredContent) + request := createMCPRequest(tc.args) + direct, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, direct.IsError) + assert.Contains(t, getErrorResult(t, direct).Text, tc.text) + } + assert.Equal(t, before, calls, "invalid input must never reach GitHub") + body = "forbidden" + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"query": query}, + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "failed to search") + assert.Contains(t, getErrorResult(t, result).Text, query) + assert.Nil(t, result.StructuredContent) + if apiErrorText == "" { + apiErrorText = getErrorResult(t, result).Text + } + assert.Equal(t, apiErrorText, getErrorResult(t, result).Text, "API errors are identical across protocols") + }) + } + } +} + +func TestSearchRepositoryOutputSchemaIsCompact(t *testing.T) { + schema := searchRepositoriesOutputSchema() + assert.Equal(t, searchRepositoryVisibilityEnum, schema.Properties["items"].Items.Properties["visibility"].Enum) + encoded := mustMarshalJSON(t, schema) + for _, field := range []string{"node_id", "custom_properties", "owner", "parent", "issues_url", "clone_url", "url\""} { + assert.NotContains(t, encoded, `"`+field, field) + } + assert.Contains(t, encoded, `"html_url"`) + for _, tool := range typedAccountRepositorySearchTools() { + assert.True(t, tool.IsReadOnly()) + } +} + +type failingSearchClientDeps struct { + BaseDeps +} + +func (failingSearchClientDeps) GetClient(context.Context) (*github.Client, error) { + return nil, errors.New("client unavailable") +} + +func TestTypedAccountRepositorySearchClientErrors(t *testing.T) { + for _, tool := range typedAccountRepositorySearchTools() { + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip, ""} { + t.Run(tool.Tool.Name+"/"+protocol, func(t *testing.T) { + session := connectTypedSearchClient(t, tool, failingSearchClientDeps{}, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"query": "query"}, + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, "failed to get GitHub client: client unavailable", getErrorResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + }) + } + } +} + +func TestSearchOutputsHaveNoUntypedFields(t *testing.T) { + seen := make(map[reflect.Type]bool) + var check func(reflect.Type) + check = func(typ reflect.Type) { + if seen[typ] { + return + } + seen[typ] = true + switch typ.Kind() { + case reflect.Interface: + t.Errorf("untyped output field: %s", typ) + case reflect.Pointer, reflect.Slice, reflect.Array: + check(typ.Elem()) + case reflect.Map: + check(typ.Elem()) + case reflect.Struct: + for field := range typ.Fields() { + if field.IsExported() && !strings.HasPrefix(field.Tag.Get("json"), "-") { + check(field.Type) + } + } + } + } + check(reflect.TypeFor[SearchRepositoriesOutput]()) + check(reflect.TypeFor[MinimalSearchUsersResult]()) + check(reflect.TypeFor[MinimalSearchCommitsResult]()) +} diff --git a/pkg/github/typed_actions_outputs_test.go b/pkg/github/typed_actions_outputs_test.go new file mode 100644 index 0000000000..7e7a8dbab4 --- /dev/null +++ b/pkg/github/typed_actions_outputs_test.go @@ -0,0 +1,613 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func actionsTypedSession(t *testing.T, deps ToolDependencies, protocol string) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + tr := translations.NullTranslationHelper + tools := []inventory.ServerTool{ActionsList(tr), ActionsGet(tr), ActionsRunTrigger(tr), ActionsGetJobLogs(tr)} + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "actions", Version: "v1"}, nil) + inv.RegisterTools(context.Background(), server, deps) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + if protocol == "" || protocol == "unknown" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch req := req.(type) { + case *mcp.ListToolsRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + case *mcp.CallToolRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + } + return next(ctx, method, req) + } + }) + } + version := protocol + if version == "" || version == "unknown" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 4) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + if os.Getenv("ACTIONS_LEGACY_BASELINE") == "true" { + continue + } + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + schemas[tool.Name] = resolved + } + return session, schemas +} + +var typedActionsProtocols = []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} + +type actionsWireCase struct { + name, method string + args map[string]any + path, query string + body, text string + status int +} + +func actionsWireCases() []actionsWireCase { + run := `{"id":7,"name":"CI","workflow_id":2,"run_number":3,"run_attempt":1,"status":"completed","conclusion":"failure","head_branch":"main","head_commit":{"message":"change"}}` + return []actionsWireCase{ + {"actions_list", "list_workflows", nil, "/repos/owner/repo/actions/workflows", "page=1&per_page=30", `{"total_count":1,"workflows":[{"id":2,"name":"CI","created_at":"2026-01-01T00:00:00Z"}]}`, `{"total_count":1,"workflows":[{"id":2,"name":"CI","created_at":"2026-01-01T00:00:00Z"}]}`, 200}, + {"actions_list", "list_workflow_runs", map[string]any{"workflow_runs_filter": map[string]any{"actor": "octocat", "branch": false, "event": 1}}, "/repos/owner/repo/actions/runs", "actor=octocat&page=1&per_page=30", `{"total_count":1,"workflow_runs":[` + run + `]}`, `{"total_count":1,"workflow_runs":[` + run + `]}`, 200}, + {"actions_list", "list_workflow_runs", map[string]any{"resource_id": "ci.yml", "page": "2e0", "perPage": "5.0"}, "/repos/owner/repo/actions/workflows/ci.yml/runs", "page=2&per_page=5", `{"total_count":0,"workflow_runs":[]}`, `{"total_count":0,"workflow_runs":[]}`, 200}, + {"actions_list", "list_workflow_runs", map[string]any{"resource_id": "2"}, "/repos/owner/repo/actions/workflows/2/runs", "page=1&per_page=30", `null`, `{"total_count":0,"workflow_runs":[]}`, 200}, + {"actions_list", "list_workflow_jobs", map[string]any{"resource_id": "7", "workflow_jobs_filter": map[string]any{"filter": "all"}}, "/repos/owner/repo/actions/runs/7/jobs", "filter=all&page=1&per_page=30", `{"total_count":1,"jobs":[{"id":8,"run_id":7,"name":"test","status":"completed"}]}`, `{"jobs":{"total_count":1,"jobs":[{"id":8,"run_id":7,"name":"test","status":"completed"}]}}`, 200}, + {"actions_list", "list_workflow_run_artifacts", map[string]any{"resource_id": "7", "workflow_jobs_filter": false}, "/repos/owner/repo/actions/runs/7/artifacts", "page=1&per_page=30", `{"total_count":1,"artifacts":[{"id":9,"digest":"sha256:abc","workflow_run":{"id":7}}]}`, `{"total_count":1,"artifacts":[{"id":9,"digest":"sha256:abc","workflow_run":{"id":7}}]}`, 200}, + {"actions_get", "get_workflow", map[string]any{"resource_id": "ci.yml"}, "/repos/owner/repo/actions/workflows/ci.yml", "", `{"id":2,"name":"CI"}`, `{"id":2,"name":"CI"}`, 200}, + {"actions_get", "get_workflow_run", map[string]any{"resource_id": "7"}, "/repos/owner/repo/actions/runs/7", "", run, run, 200}, + {"actions_get", "get_workflow_job", map[string]any{"resource_id": "8"}, "/repos/owner/repo/actions/jobs/8", "", `{"id":8,"steps":[{"name":"test","number":1,"started_at":"2026-01-01T00:00:00Z"}],"labels":["linux"]}`, `{"id":8,"steps":[{"name":"test","number":1,"started_at":"2026-01-01T00:00:00Z"}],"labels":["linux"]}`, 200}, + {"actions_get", "get_workflow_run_usage", map[string]any{"resource_id": "7"}, "/repos/owner/repo/actions/runs/7/timing", "", `{"billable":{"CUSTOM":{"total_ms":42,"jobs":1,"job_runs":[{"job_id":8,"duration_ms":42}]}},"run_duration_ms":42}`, `{"billable":{"CUSTOM":{"total_ms":42,"jobs":1,"job_runs":[{"job_id":8,"duration_ms":42}]}},"run_duration_ms":42}`, 200}, + {"actions_get", "get_workflow", map[string]any{"resource_id": "-2"}, "/repos/owner/repo/actions/workflows/-2", "", `{}`, `{}`, 200}, + {"actions_get", "get_workflow_job", map[string]any{"resource_id": "8"}, "/repos/owner/repo/actions/jobs/8", "", `null`, `null`, 200}, + {"actions_get", "get_workflow_run_usage", map[string]any{"resource_id": "7"}, "/repos/owner/repo/actions/runs/7/timing", "", `{}`, `{}`, 200}, + {"actions_list", "list_workflows", map[string]any{"page": 0, "perPage": 0, "workflow_runs_filter": nil}, "/repos/owner/repo/actions/workflows", "page=1&per_page=30", `{}`, `{}`, 200}, + {"actions_list", "list_workflow_run_artifacts", map[string]any{"resource_id": "7"}, "/repos/owner/repo/actions/runs/7/artifacts", "page=1&per_page=30", `{}`, `{}`, 200}, + {"actions_list", "list_workflow_runs", map[string]any{"workflow_runs_filter": map[string]any{"event": "custom", "status": "custom"}}, "/repos/owner/repo/actions/runs", "event=custom&page=1&per_page=30&status=custom", `{}`, `{"total_count":0,"workflow_runs":[]}`, 200}, + {"actions_get", "download_workflow_run_artifact", map[string]any{"resource_id": "9"}, "/repos/owner/repo/actions/artifacts/9/zip", "", "", `{"artifact_id":9,"download_url":"https://logs.example/archive","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."}`, 302}, + {"actions_get", "get_workflow_run_logs_url", map[string]any{"resource_id": "7"}, "/repos/owner/repo/actions/runs/7/logs", "", "", `{"logs_url":"https://logs.example/archive","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.","optimization_tip":"Use: get_job_logs with parameters {run_id: 7, failed_only: true} for more efficient failed job debugging","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."}`, 302}, + {"actions_run_trigger", "run_workflow", map[string]any{"workflow_id": "ci.yml", "ref": "main"}, "/repos/owner/repo/actions/workflows/ci.yml/dispatches", "", "", `{"inputs":null,"message":"Workflow run has been queued","ref":"main","status":"204 No Content","status_code":204,"workflow_id":"ci.yml","workflow_type":"workflow_file"}`, 204}, + {"actions_run_trigger", "run_workflow", map[string]any{"workflow_id": "2", "ref": "main", "inputs": map[string]any{"flag": true, "nested": []any{nil, 42, map[string]any{"s": "x"}}}}, "/repos/owner/repo/actions/workflows/2/dispatches", "", "", `{"inputs":{"flag":true,"nested":[null,42,{"s":"x"}]},"message":"Workflow run has been queued","ref":"main","status":"204 No Content","status_code":204,"workflow_id":"2","workflow_type":"workflow_id"}`, 204}, + {"actions_run_trigger", "rerun_workflow_run", map[string]any{"run_id": "7.0", "workflow_id": false, "ref": nil}, "/repos/owner/repo/actions/runs/7/rerun", "", "", `{"message":"Workflow run has been queued for re-run","run_id":7,"status":"201 Created","status_code":201}`, 201}, + {"actions_run_trigger", "rerun_failed_jobs", map[string]any{"run_id": "7e0"}, "/repos/owner/repo/actions/runs/7/rerun-failed-jobs", "", "", `{"message":"Failed jobs have been queued for re-run","run_id":7,"status":"201 Created","status_code":201}`, 201}, + {"actions_run_trigger", "cancel_workflow_run", map[string]any{"run_id": 7}, "/repos/owner/repo/actions/runs/7/cancel", "", `{}`, `{"message":"Workflow run has been cancelled","run_id":7,"status":"202 Accepted","status_code":202}`, 202}, + {"actions_run_trigger", "delete_workflow_run_logs", map[string]any{"run_id": 7}, "/repos/owner/repo/actions/runs/7/logs", "", "", `{"message":"Workflow run logs have been deleted","run_id":7,"status":"204 No Content","status_code":204}`, 204}, + } +} + +func actionsCaseArgs(tc actionsWireCase) map[string]any { + args := map[string]any{"owner": "owner", "repo": "repo"} + if tc.method != "" { + args["method"] = tc.method + } + maps.Copy(args, tc.args) + return args +} + +func actionsCaseDeps(t *testing.T, current **actionsWireCase, apiError bool) BaseDeps { + t.Helper() + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Host == "logs.example" { + w.Header().Set("Location", "https://logs.example/archive") + w.WriteHeader(http.StatusFound) + return + } + tc := *current + assert.Equal(t, tc.path, r.URL.Path) + assert.Equal(t, tc.query, r.URL.RawQuery) + if apiError { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + return + } + if tc.method == "run_workflow" { + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, tc.args["ref"], payload["ref"]) + assert.JSONEq(t, mustMarshalJSON(t, tc.args["inputs"]), mustMarshalJSON(t, payload["inputs"])) + } + if tc.status == 302 { + w.Header().Set("Location", "https://logs.example/archive") + } + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })}} + return BaseDeps{Client: mustNewGHClient(t, client), ContentWindowSize: 100, + RepoAccessCache: stubRepoAccessCache(nil, time.Minute)} +} + +func assertActionsWireResult(t *testing.T, result *mcp.CallToolResult, schema *jsonschema.Resolved, text string, isError bool, projected ...string) { + t.Helper() + require.Equal(t, isError, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + if schema == nil || isError { + assert.Equal(t, text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + return + } + if text != "null" { + require.NotNil(t, result.StructuredContent) + } + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + expected := text + if len(projected) != 0 { + expected = projected[0] + } + assert.JSONEq(t, expected, mustMarshalJSON(t, output)) + assert.Equal(t, mustMarshalJSON(t, output), getTextResult(t, result).Text) +} + +func TestTypedActionsWireOutputs(t *testing.T) { + cases := append(actionsWireCases(), + actionsWireCase{name: "actions_list", method: "list_workflows", path: "/repos/owner/repo/actions/workflows", query: "page=1&per_page=30", body: "null", text: "null", status: 200}, + actionsWireCase{name: "actions_list", method: "list_workflows", path: "/repos/owner/repo/actions/workflows", query: "page=1&per_page=30", body: `{"total_count":0,"workflows":[]}`, text: `{"total_count":0}`, status: 200}, + actionsWireCase{name: "actions_list", method: "list_workflow_run_artifacts", args: map[string]any{"resource_id": "7"}, path: "/repos/owner/repo/actions/runs/7/artifacts", query: "page=1&per_page=30", body: "null", text: "null", status: 200}, + actionsWireCase{name: "actions_list", method: "list_workflow_run_artifacts", args: map[string]any{"resource_id": "7"}, path: "/repos/owner/repo/actions/runs/7/artifacts", query: "page=1&per_page=30", body: `{"total_count":0,"artifacts":[]}`, text: `{"total_count":0}`, status: 200}, + actionsWireCase{name: "actions_get", method: "get_workflow_job", args: map[string]any{"resource_id": "8"}, path: "/repos/owner/repo/actions/jobs/8", body: `{}`, text: `{}`, status: 200}, + actionsWireCase{name: "actions_get", method: "get_workflow", args: map[string]any{"resource_id": "ci.yml"}, path: "/repos/owner/repo/actions/workflows/ci.yml", body: `null`, text: `null`, status: 200}, + actionsWireCase{name: "actions_get", method: "get_workflow_run_usage", args: map[string]any{"resource_id": "7"}, path: "/repos/owner/repo/actions/runs/7/timing", body: `null`, text: `null`, status: 200}, + actionsWireCase{name: "actions_list", method: "list_workflows", args: map[string]any{"page": -1, "perPage": 101}, path: "/repos/owner/repo/actions/workflows", query: "page=-1&per_page=101", body: `{}`, text: `{}`, status: 200}, + actionsWireCase{name: "actions_list", method: "list_workflows", args: map[string]any{"page": 1, "perPage": -1}, path: "/repos/owner/repo/actions/workflows", query: "page=1&per_page=-1", body: `{}`, text: `{}`, status: 200}, + ) + for _, protocol := range typedActionsProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + var current *actionsWireCase + session, schemas := actionsTypedSession(t, actionsCaseDeps(t, ¤t, false), protocol) + for _, tc := range cases { + t.Run(tc.name+"/"+tc.method+"/"+mustMarshalJSON(t, tc.args), func(t *testing.T) { + current = &tc + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: actionsCaseArgs(tc)}) + require.NoError(t, err) + projected := tc.text + switch { + case tc.method == "list_workflow_jobs": + var legacy ActionsJobsOutput + require.NoError(t, json.Unmarshal([]byte(tc.text), &legacy)) + projected = mustMarshalJSON(t, legacy.Jobs) + case tc.method == "get_workflow_job" && tc.body == "{}": + projected = `{"id":0,"run_id":0,"name":"","status":""}` + case tc.method == "get_workflow_job" && tc.body != "null": + projected = `{"id":8,"run_id":0,"name":"","status":"","steps":[{"name":"test","number":1,"status":"","started_at":"2026-01-01T00:00:00Z"}],"labels":["linux"]}` + case tc.method == "get_workflow_run_usage" && tc.body != "{}" && tc.body != "null": + projected = `{"billable":[{"runner":"CUSTOM","total_ms":42,"jobs":1,"job_runs":[{"job_id":8,"duration_ms":42}]}],"run_duration_ms":42}` + } + projected = actionsProjectedEnvelope(t, tc.method, projected) + assertActionsWireResult(t, result, schemas[tc.name], tc.text, false, projected) + }) + } + }) + } +} + +func actionsProjectedEnvelope(t *testing.T, method, projected string) string { + t.Helper() + field := map[string]string{ + "list_workflows": "workflows", "list_workflow_runs": "workflow_runs", + "list_workflow_jobs": "workflow_jobs", "list_workflow_run_artifacts": "artifacts", + "get_workflow": "workflow", "get_workflow_run": "workflow_run", "get_workflow_job": "workflow_job", + "get_workflow_run_usage": "usage", "download_workflow_run_artifact": "artifact", "get_workflow_run_logs_url": "logs", + "run_workflow": "dispatch", "rerun_workflow_run": "rerun", "rerun_failed_jobs": "rerun_failed", + "cancel_workflow_run": "cancel", "delete_workflow_run_logs": "delete_logs", + }[method] + require.NotEmpty(t, field) + envelope := map[string]any{"method": method} + if projected != "null" { + envelope[field] = json.RawMessage(projected) + } + return mustMarshalJSON(t, envelope) +} + +func TestTypedActionsWireErrors(t *testing.T) { + cases := []actionsWireCase{ + {name: "actions_list", args: map[string]any{"owner": "", "method": false}, text: "missing required parameter: owner"}, + {name: "actions_list", method: "list_workflows", args: map[string]any{"resource_id": 7}, text: "parameter resource_id is not of type string, is float64"}, + {name: "actions_list", method: "list_workflows", args: map[string]any{"page": "bad"}, text: "parameter page is not a valid number: invalid numeric value: bad"}, + {name: "actions_list", method: "list_workflow_jobs", text: "missing required parameter for method list_workflow_jobs: resource_id"}, + {name: "actions_list", method: "list_workflow_jobs", args: map[string]any{"resource_id": "bad", "workflow_jobs_filter": false}, text: `invalid resource_id, must be an integer for method list_workflow_jobs: strconv.ParseInt: parsing "bad": invalid syntax`}, + {name: "actions_list", method: "list_workflow_runs", args: map[string]any{"workflow_runs_filter": false}, text: "parameter workflow_runs_filter is not of type map[string]interface {}, is bool"}, + {name: "actions_list", method: "list_workflow_jobs", args: map[string]any{"resource_id": "7", "workflow_jobs_filter": nil}, text: "parameter workflow_jobs_filter is not of type map[string]interface {}, is "}, + {name: "actions_list", method: "list_workflow_jobs", args: map[string]any{"workflow_jobs_filter": false}, text: "missing required parameter for method list_workflow_jobs: resource_id"}, + {name: "actions_list", method: "unknown", args: map[string]any{"resource_id": "7"}, text: "unknown method: unknown"}, + {name: "actions_get", method: "get_workflow", text: "missing required parameter: resource_id"}, + {name: "actions_get", method: "get_workflow_job", args: map[string]any{"resource_id": "7.0"}, text: `invalid resource_id, must be an integer for method get_workflow_job: strconv.ParseInt: parsing "7.0": invalid syntax`}, + {name: "actions_get", method: "unknown", args: map[string]any{"resource_id": "7"}, text: "unknown method: unknown"}, + {name: "actions_run_trigger", method: "run_workflow", args: map[string]any{"workflow_id": false}, text: "workflow_id is required for run_workflow action"}, + {name: "actions_run_trigger", method: "run_workflow", args: map[string]any{"workflow_id": "ci.yml", "ref": false}, text: "ref is required for run_workflow action"}, + {name: "actions_run_trigger", method: "rerun_workflow_run", args: map[string]any{"run_id": "bad"}, text: "missing required parameter: run_id"}, + {name: "actions_run_trigger", method: "run_workflow", args: map[string]any{"inputs": nil}, text: "parameter inputs is not of type map[string]interface {}, is "}, + {name: "get_job_logs", args: map[string]any{"job_id": 1.5}, text: "parameter job_id is not a valid number: non-integer numeric value: 1.5"}, + {name: "get_job_logs", args: map[string]any{"job_id": "8", "failed_only": nil}, text: "parameter failed_only is not of type bool, is "}, + {name: "get_job_logs", args: map[string]any{"failed_only": true}, text: "run_id is required when failed_only is true"}, + {name: "get_job_logs", args: map[string]any{"job_id": -1}, text: "Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"}, + } + + for _, protocol := range typedActionsProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + var current *actionsWireCase + session, schemas := actionsTypedSession(t, actionsCaseDeps(t, ¤t, false), protocol) + for _, tc := range cases { + t.Run(tc.name+"/"+tc.text, func(t *testing.T) { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: actionsCaseArgs(tc)}) + require.NoError(t, err) + assertActionsWireResult(t, result, schemas[tc.name], tc.text, true) + }) + } + session, _ = actionsTypedSession(t, actionsCaseDeps(t, ¤t, true), protocol) + for _, tc := range actionsWireCases() { + current = &tc + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: actionsCaseArgs(tc)}) + require.NoError(t, err) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + assert.Contains(t, getTextResult(t, result).Text, "Forbidden") + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedActionsClientErrorPrecedence(t *testing.T) { + for _, protocol := range typedActionsProtocols { + session, _ := actionsTypedSession(t, failingSearchClientDeps{}, protocol) + for _, tc := range []actionsWireCase{ + {name: "actions_list", method: "list_workflow_jobs", args: map[string]any{"resource_id": "bad", "workflow_jobs_filter": false}}, + {name: "actions_list", method: "list_workflow_runs", args: map[string]any{"workflow_runs_filter": nil}}, + {name: "actions_get", method: "unknown", args: map[string]any{"resource_id": "bad"}}, + {name: "get_job_logs", args: map[string]any{"failed_only": true}}, + {name: "actions_run_trigger", method: "unknown", args: map[string]any{"run_id": 7}}, + } { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: actionsCaseArgs(tc)}) + if os.Getenv("ACTIONS_LEGACY_BASELINE") == "true" { + require.EqualError(t, err, `calling "tools/call": failed to get GitHub client: client unavailable`) + continue + } + require.NoError(t, err) + assertActionsWireResult(t, result, nil, "failed to get GitHub client: client unavailable", true) + } + } +} + +func TestTypedActionsIFCAndAnnotations(t *testing.T) { + for _, protocol := range typedActionsProtocols { + for _, private := range []bool{false, true} { + for _, enabled := range []bool{false, true} { + for _, tc := range []actionsWireCase{ + {name: "actions_list", method: "list_workflows"}, + {name: "actions_get", method: "get_workflow", args: map[string]any{"resource_id": "ci.yml"}}, + {name: "get_job_logs", args: map[string]any{"run_id": "7e0", "failed_only": true}}, + {name: "actions_run_trigger", method: "delete_workflow_run_logs", args: map[string]any{"run_id": 7}}, + } { + t.Run(fmt.Sprintf("%s/%s/private=%t/enabled=%t", tc.name, protocol, private, enabled), func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/repos/owner/repo" { + _, _ = fmt.Fprintf(w, `{"private":%t}`, private) + return + } + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + return + } + _, _ = w.Write([]byte(`{}`)) + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client), + RepoAccessCache: stubRepoAccessCache(nil, time.Minute)} + if enabled { + deps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + } + session, schemas := actionsTypedSession(t, deps, protocol) + listed, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + for _, tool := range listed.Tools { + require.NotNil(t, tool.Annotations) + assert.Equal(t, tool.Name != "actions_run_trigger", tool.Annotations.ReadOnlyHint) + if tool.Name == "actions_run_trigger" { + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.True(t, *tool.Annotations.DestructiveHint) + } + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: actionsCaseArgs(tc)}) + require.NoError(t, err) + text := getTextResult(t, result).Text + projected := text + if tc.method != "" { + legacy := "{}" + if tc.method == "delete_workflow_run_logs" { + legacy = `{"message":"Workflow run logs have been deleted","run_id":7,"status":"204 No Content","status_code":204}` + } + projected = actionsProjectedEnvelope(t, tc.method, legacy) + text = legacy + } + assertActionsWireResult(t, result, schemas[tc.name], text, false, projected) + if !enabled || tc.name == "actions_run_trigger" { + assert.Nil(t, result.Meta["ifc"]) + return + } + label := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", label["integrity"]) + confidentiality := "public" + if private { + confidentiality = "private" + } + assert.Equal(t, confidentiality, label["confidentiality"]) + }) + } + } + } + } +} + +func TestTypedActionsMinimalProjection(t *testing.T) { + tests := []struct { + actionsWireCase + raw any + projected string + }{ + { + actionsWireCase: actionsWireCase{name: "actions_get", method: "get_workflow", args: map[string]any{"resource_id": "ci.yml"}, path: "/repos/owner/repo/actions/workflows/ci.yml", status: 200}, + raw: &github.Workflow{ + ID: new(int64(2)), Name: new("CI"), State: new("active"), Path: new(".github/workflows/ci.yml"), + NodeID: new("node"), URL: new("https://api.github.com/workflows/2"), + HTMLURL: new("https://github.com/owner/repo/actions/workflows/ci.yml"), BadgeURL: new("https://badge"), + }, + projected: `{"id":2,"name":"CI","state":"active","path":".github/workflows/ci.yml","html_url":"https://github.com/owner/repo/actions/workflows/ci.yml"}`, + }, + { + actionsWireCase: actionsWireCase{name: "actions_list", method: "list_workflows", path: "/repos/owner/repo/actions/workflows", query: "page=1&per_page=30", status: 200}, + raw: &github.Workflows{TotalCount: new(1), Workflows: []*github.Workflow{{ + ID: new(int64(2)), Name: new("CI"), URL: new("https://api.github.com/workflows/2"), BadgeURL: new("https://badge"), + }}}, + projected: `{"total_count":1,"workflows":[{"id":2,"name":"CI"}]}`, + }, + { + actionsWireCase: actionsWireCase{name: "actions_list", method: "list_workflow_run_artifacts", args: map[string]any{"resource_id": "7"}, path: "/repos/owner/repo/actions/runs/7/artifacts", query: "page=1&per_page=30", status: 200}, + raw: &github.ArtifactList{TotalCount: new(int64(1)), Artifacts: []*github.Artifact{{ + ID: new(int64(9)), Name: new("build"), SizeInBytes: new(int64(0)), Expired: new(false), + Digest: new("sha256:abc"), NodeID: new("node"), URL: new("https://api.github.com/artifacts/9"), + ArchiveDownloadURL: new("https://api.github.com/artifacts/9/zip"), + WorkflowRun: &github.ArtifactWorkflowRun{ID: new(int64(7)), HeadBranch: new("main"), HeadSHA: new("abc")}, + }}}, + projected: `{"total_count":1,"artifacts":[{"id":9,"name":"build","size_in_bytes":0,"expired":false,"digest":"sha256:abc","workflow_run":{"id":7,"head_branch":"main","head_sha":"abc"}}]}`, + }, + { + actionsWireCase: actionsWireCase{name: "actions_get", method: "get_workflow_job", args: map[string]any{"resource_id": "8"}, path: "/repos/owner/repo/actions/jobs/8", status: 200}, + raw: &github.WorkflowJob{ + ID: new(int64(8)), RunID: new(int64(7)), Name: new("test"), Status: new("completed"), Conclusion: new("failure"), + NodeID: new("node"), URL: new("https://api.github.com/jobs/8"), RunURL: new("https://api.github.com/runs/7"), + HTMLURL: new("https://github.com/owner/repo/actions/runs/7/job/8"), + }, + projected: `{"id":8,"run_id":7,"name":"test","status":"completed","conclusion":"failure","html_url":"https://github.com/owner/repo/actions/runs/7/job/8"}`, + }, + { + actionsWireCase: actionsWireCase{name: "actions_get", method: "get_workflow_run_usage", args: map[string]any{"resource_id": "7"}, path: "/repos/owner/repo/actions/runs/7/timing", status: 200}, + raw: &github.WorkflowRunUsage{RunDurationMS: new(int64(42)), Billable: &github.WorkflowRunBillMap{ + "CUSTOM": {TotalMS: new(int64(42)), Jobs: new(1), JobRuns: []*github.WorkflowRunJobRun{{JobID: new(8), DurationMS: new(int64(42))}}}, + "ANOTHER": nil, + }}, + projected: `{"billable":[{"runner":"ANOTHER"},{"runner":"CUSTOM","total_ms":42,"jobs":1,"job_runs":[{"job_id":8,"duration_ms":42}]}],"run_duration_ms":42}`, + }, + } + for _, protocol := range typedActionsProtocols { + for _, tc := range tests { + t.Run(protocol+"/"+tc.method, func(t *testing.T) { + current := tc.actionsWireCase + current.body = mustMarshalJSON(t, tc.raw) + current.text = current.body + currentCase := ¤t + session, schemas := actionsTypedSession(t, actionsCaseDeps(t, ¤tCase, false), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: current.name, Arguments: actionsCaseArgs(current)}) + require.NoError(t, err) + assertActionsWireResult(t, result, schemas[current.name], current.text, false, actionsProjectedEnvelope(t, current.method, tc.projected)) + }) + } + } +} + +func TestActionsConcreteSchemas(t *testing.T) { + for name, schema := range map[string]*jsonschema.Schema{ + "list": actionsListOutputSchema(), "get": actionsGetOutputSchema(), + "trigger": actionsRunTriggerOutputSchema(), "logs": actionsJobLogsOutputSchema(), + } { + t.Run(name, func(t *testing.T) { + if name != "logs" { + assert.Equal(t, "object", schema.Type) + assert.Empty(t, schema.AnyOf) + assert.Len(t, schema.OneOf, len(schema.Properties["method"].Enum)) + require.NotEmpty(t, schema.Properties["method"].Enum) + } + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + if name != "logs" { + require.Error(t, resolved.Validate(nil)) + } + for _, raw := range []string{`[]`, `42`, `{"unknown":true}`, `{"id":"wrong"}`, `{"jobs":"wrong"}`, `{"logs_url":false}`, `{"method":"unknown"}`} { + var invalid any + require.NoError(t, json.Unmarshal([]byte(raw), &invalid)) + require.Error(t, resolved.Validate(invalid), raw) + } + }) + } + seen := make(map[reflect.Type]bool) + var check func(reflect.Type) + check = func(typ reflect.Type) { + if seen[typ] || typ == reflect.TypeFor[json.RawMessage]() { + return + } + seen[typ] = true + switch typ.Kind() { + case reflect.Interface: + t.Errorf("untyped Actions output: %s", typ) + case reflect.Map: + t.Errorf("map Actions output: %s", typ) + case reflect.Array, reflect.Slice, reflect.Pointer: + check(typ.Elem()) + case reflect.Struct: + assert.NotEqual(t, "github.com/google/go-github/v92/github", typ.PkgPath(), "raw GitHub output: %s", typ) + for field := range typ.Fields() { + if field.IsExported() { + check(field.Type) + } + } + } + } + check(reflect.TypeFor[ActionsListOutput]()) + check(reflect.TypeFor[ActionsGetOutput]()) + check(reflect.TypeFor[ActionsRunTriggerOutput]()) + check(reflect.TypeFor[ActionsJobLogsOutput]()) +} + +func TestActionsOutputSchemasCached(t *testing.T) { + for _, schema := range []func() *jsonschema.Schema{ + actionsListOutputSchema, actionsGetOutputSchema, actionsRunTriggerOutputSchema, actionsJobLogsOutputSchema, + } { + assert.Same(t, schema(), schema()) + } +} + +func TestActionsAdvertisedInputMetadata(t *testing.T) { + tr := translations.NullTranslationHelper + list := ActionsList(tr).Tool.InputSchema.(*jsonschema.Schema) + get := ActionsGet(tr).Tool.InputSchema.(*jsonschema.Schema) + trigger := ActionsRunTrigger(tr).Tool.InputSchema.(*jsonschema.Schema) + assert.Equal(t, []any{"list_workflows", "list_workflow_runs", "list_workflow_jobs", "list_workflow_run_artifacts"}, list.Properties["method"].Enum) + assert.Equal(t, []any{"get_workflow", "get_workflow_run", "get_workflow_job", "download_workflow_run_artifact", "get_workflow_run_usage", "get_workflow_run_logs_url"}, get.Properties["method"].Enum) + assert.Equal(t, []any{"run_workflow", "rerun_workflow_run", "rerun_failed_jobs", "cancel_workflow_run", "delete_workflow_run_logs"}, trigger.Properties["method"].Enum) + runs := list.Properties["workflow_runs_filter"] + assert.Equal(t, "object", runs.Type) + assert.Empty(t, runs.AnyOf) + assert.Len(t, runs.Properties["event"].Enum, 32) + assert.Equal(t, []any{"queued", "in_progress", "completed", "requested", "waiting"}, runs.Properties["status"].Enum) + assert.Equal(t, []any{"latest", "all"}, list.Properties["workflow_jobs_filter"].Properties["filter"].Enum) + require.NotNil(t, list.Properties["page"].Minimum) + require.NotNil(t, list.Properties["perPage"].Minimum) + require.NotNil(t, list.Properties["perPage"].Maximum) + assert.Equal(t, 1.0, *list.Properties["page"].Minimum) + assert.Equal(t, 1.0, *list.Properties["perPage"].Minimum) + assert.Equal(t, 100.0, *list.Properties["perPage"].Maximum) + assert.Equal(t, "The action to perform", list.Properties["method"].Description) +} + +func TestActionsOutputEnumValues(t *testing.T) { + get := actionsGetOutputSchema() + assert.Equal(t, []any{"active", "deleted", "disabled_fork", "disabled_inactivity", "disabled_manually"}, get.Properties["workflow"].Properties["state"].Enum) + assert.Equal(t, inventory.EnumSchema(append(WorkflowStatusValues(), "")...).Enum, get.Properties["workflow_job"].Properties["status"].Enum) + assert.Equal(t, inventory.EnumSchema(WorkflowConclusionValues()...).Enum, get.Properties["workflow_run"].Properties["conclusion"].Enum) + for _, name := range []string{"workflow", "workflow_run", "workflow_job"} { + schema := get.Properties[name] + assert.Equal(t, "date-time", schema.Properties["created_at"].Format) + assert.Equal(t, "Timestamp in RFC3339 format.", schema.Properties["created_at"].Description) + } + usage := get.Properties["usage"] + assert.Equal(t, "Duration in milliseconds.", usage.Properties["run_duration_ms"].Description) + assert.Equal(t, "Duration in milliseconds.", usage.Properties["billable"].Items.Properties["total_ms"].Description) +} + +func TestTypedActionsJobLogWireOutputs(t *testing.T) { + logServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("first\nsecond\nthird")) + })) + defer logServer.Close() + const 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." + for _, protocol := range typedActionsProtocols { + for _, outcome := range []string{"single", "failed", "partial", "none"} { + for _, content := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/content=%t", protocol, outcome, content), func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/jobs"): + body := `{"total_count":2,"jobs":[{"id":8,"name":"test","conclusion":"failure"},{"id":9,"name":"build","conclusion":"failure"}]}` + if outcome == "none" { + body = `{"total_count":1,"jobs":[{"id":8,"conclusion":"success"}]}` + } + _, _ = w.Write([]byte(body)) + case outcome == "partial" && strings.HasSuffix(r.URL.Path, "/9/logs"): + w.WriteHeader(403) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + default: + w.Header().Set("Location", logServer.URL) + w.WriteHeader(302) + } + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client), ContentWindowSize: 100, + RepoAccessCache: stubRepoAccessCache(nil, time.Minute)} + session, schemas := actionsTypedSession(t, deps, protocol) + args := map[string]any{"owner": "owner", "repo": "repo", "return_content": content, "tail_lines": "2.0"} + if outcome == "single" { + args["job_id"] = "8e0" + } else { + args["run_id"], args["failed_only"] = "7", true + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "get_job_logs", Arguments: args}) + require.NoError(t, err) + text := getTextResult(t, result).Text + assertActionsWireResult(t, result, schemas["get_job_logs"], text, false) + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(text), &payload)) + switch { + case outcome == "none": + assertActionsLogText(t, schemas["get_job_logs"], `{"failed_jobs":0,"message":"No failed jobs found in this workflow run","run_id":7,"total_jobs":1}`, text) + case outcome == "single" && content: + assertActionsLogText(t, schemas["get_job_logs"], `{"job_id":8,"logs_content":"second\nthird","message":"Job logs content retrieved successfully","original_length":3}`, text) + case outcome == "single": + assertActionsLogText(t, schemas["get_job_logs"], fmt.Sprintf(`{"job_id":8,"logs_url":%q,"message":"Job logs are available for download","note":%q}`, logServer.URL, note), text) + default: + logs := payload["logs"].([]any) + require.Len(t, logs, 2) + if outcome == "partial" { + assert.Contains(t, logs[1].(map[string]any)["error"], "Forbidden") + } + + require.Equal(t, float64(2), payload["failed_jobs"]) + require.Equal(t, "test", logs[0].(map[string]any)["job_name"]) + } + }) + } + } + } +} + +func assertActionsLogText(t *testing.T, schema *jsonschema.Resolved, expected, actual string) { + t.Helper() + if schema == nil { + require.Equal(t, expected, actual) + } else { + require.JSONEq(t, expected, actual) + } +} diff --git a/pkg/github/typed_compatibility_test.go b/pkg/github/typed_compatibility_test.go new file mode 100644 index 0000000000..d1e68a6b1f --- /dev/null +++ b/pkg/github/typed_compatibility_test.go @@ -0,0 +1,288 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "testing" + + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTypedCompatibilityCommitInputs(t *testing.T) { + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + for _, tool := range []inventory.ServerTool{GetCommit(translations.NullTranslationHelper), ListCommits(translations.NullTranslationHelper)} { + for _, pagination := range []map[string]any{ + {"page": -1, "perPage": 101}, + {"page": "-1", "perPage": "-2"}, + {"page": 2, "perPage": 101}, + {"page": "2", "perPage": "-2"}, + {"fields": nil}, + } { + t.Run(protocol+"/"+tool.Tool.Name+"/"+mustMarshalJSON(t, pagination), func(t *testing.T) { + calls := 0 + handler := func(w http.ResponseWriter, r *http.Request) { + calls++ + if pagination["page"] != nil { + assert.Equal(t, fmt.Sprint(pagination["page"]), r.URL.Query().Get("page")) + assert.Equal(t, fmt.Sprint(pagination["perPage"]), r.URL.Query().Get("per_page")) + } + var response any = &github.RepositoryCommit{SHA: new("abc")} + if tool.Tool.Name == "list_commits" { + response = []*github.RepositoryCommit{{SHA: new("abc")}} + } + mockResponse(t, http.StatusOK, response)(w, r) + } + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepoByRef: handler, + GetReposCommitsByOwnerByRepo: handler, + }))} + args := map[string]any{"owner": "owner", "repo": "repo", "sha": "abc"} + maps.Copy(args, pagination) + session := connectTypedSearchClient(t, tool, deps, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + assert.Equal(t, 1, calls) + if protocol == "2025-11-25" { + assert.Nil(t, result.StructuredContent) + } else { + assert.JSONEq(t, getTextResult(t, result).Text, mustMarshalJSON(t, result.StructuredContent)) + } + request := createMCPRequest(args) + direct, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, direct.IsError, mustMarshalJSON(t, direct)) + assert.Equal(t, 2, calls) + }) + } + } + } +} +func TestTypedCompatibilityGranularOptionalZero(t *testing.T) { + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + for _, tc := range []struct { + tool inventory.ServerTool + args map[string]any + body string + }{ + {GranularReprioritizeSubIssue(translations.NullTranslationHelper), map[string]any{"sub_issue_id": 7, "after_id": "0", "before_id": 8}, `{"sub_issue_id":7,"before_id":8}`}, + {GranularReprioritizeSubIssue(translations.NullTranslationHelper), map[string]any{"sub_issue_id": 7, "after_id": 8, "before_id": "0"}, `{"sub_issue_id":7,"after_id":8}`}, + {GranularUpdateIssueState(translations.NullTranslationHelper), map[string]any{"state": "open", "duplicate_of": "0"}, `{"state":"open"}`}, + } { + t.Run(protocol+"/"+tc.tool.Tool.Name+"/"+tc.body, func(t *testing.T) { + calls := 0 + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + var body any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.JSONEq(t, tc.body, mustMarshalJSON(t, body)) + _, _ = w.Write([]byte(`{"id":1,"number":1}`)) + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client), featureChecker: featureCheckerFor(inventory.FeatureFlag(FeatureFlagIssuesGranular))} + args := map[string]any{"owner": "owner", "repo": "repo", "issue_number": 1} + maps.Copy(args, tc.args) + session := connectTypedSearchClient(t, tc.tool, deps, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + assert.Equal(t, 1, calls) + }) + } + } +} + +func TestTypedCompatibilityGranularInputDiagnostics(t *testing.T) { + for _, tool := range granularToolsForToolset(ToolsetMetadataIssues.ID, FeatureFlagIssuesGranular) { + if tool.Tool.Name == "hide_issue_comment" || tool.Tool.Name == "unhide_issue_comment" { + continue + } + _, err := tool.GetInputNormalizer()(json.RawMessage(`{}`)) + var inputError *inventory.ToolInputError + require.ErrorAs(t, err, &inputError, tool.Tool.Name) + assert.Equal(t, "missing required parameter: owner", inputError.Message, tool.Tool.Name) + } + tool := GranularUpdateIssueState(translations.NullTranslationHelper) + deps := BaseDeps{featureChecker: featureCheckerFor(inventory.FeatureFlag(FeatureFlagIssuesGranular))} + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + session := connectTypedSearchClient(t, tool, deps, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"owner": "owner", "repo": "repo", "issue_number": 1, "state": "open", "state_reason": "completed"}, + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, "state_reason can only be used when state is 'closed'", getErrorResult(t, result).Text) + } +} + +func TestTypedCompatibilityPullRequestAfter(t *testing.T) { + tool := PullRequestRead(translations.NullTranslationHelper) + for _, method := range pullRequestArgumentSpecs["read"].methods { + args := map[string]any{"method": method, "owner": "owner", "repo": "repo", "pullNumber": 1, "after": 42} + normalized, err := tool.GetInputNormalizer()(json.RawMessage(mustMarshalJSON(t, args))) + if method == "get_review_comments" { + require.ErrorContains(t, err, "after") + } else { + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal(normalized, &parsed)) + assert.NotContains(t, parsed, "after", method) + } + } + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsFilesByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, []*github.CommitFile{}), + }))} + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + session := connectTypedSearchClient(t, tool, deps, protocol) + for _, method := range []string{"get_files", "get_review_comments"} { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"method": method, "owner": "owner", "repo": "repo", "pullNumber": 1, "after": 42}, + }) + require.NoError(t, err) + if method == "get_review_comments" { + assert.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "after") + } else { + require.False(t, result.IsError, mustMarshalJSON(t, result)) + assert.Equal(t, "[]", getTextResult(t, result).Text) + } + + } + } +} + +func TestTypedCompatibilitySearchFieldValuePresence(t *testing.T) { + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + for _, tool := range []inventory.ServerTool{SearchIssues(translations.NullTranslationHelper), SearchPullRequests(translations.NullTranslationHelper)} { + for _, available := range []bool{false, true} { + if available && tool.Tool.Name == "search_pull_requests" { + continue // Pull request search does not enrich custom issue fields. + } + t.Run(fmt.Sprintf("%s/%s/available=%t", protocol, tool.Tool.Name, available), func(t *testing.T) { + issue := &github.Issue{Number: new(1), Title: new("Subject"), NodeID: new("I_1")} + response := githubv4mock.ErrorResponse("Field 'issueFieldValues' doesn't exist on type 'Issue'") + if available { + response = githubv4mock.DataResponse(map[string]any{"nodes": []any{ + map[string]any{"id": "I_1", "issueFieldValues": map[string]any{"nodes": []any{}}}, + }}) + } + matcher := githubv4mock.NewQueryMatcher(searchIssueFieldValuesQueryString, map[string]any{"ids": []any{"I_1"}}, response) + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, &github.IssuesSearchResult{Total: new(1), IncompleteResults: new(false), Issues: []*github.Issue{issue}}), + })), + GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)), + } + session := connectTypedSearchClient(t, tool, deps, protocol) + for _, fields := range [][]string{nil, {"field_values"}, {"title"}} { + if tool.Tool.Name == "search_pull_requests" && len(fields) > 0 && fields[0] == "field_values" { + continue + } + args := map[string]any{"query": "repo:owner/repo"} + if fields != nil { + args["fields"] = fields + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + var output struct { + Items []map[string]json.RawMessage `json:"items"` + } + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &output)) + require.Len(t, output.Items, 1) + if available && (fields == nil || fields[0] == "field_values") { + assert.Equal(t, "[]", string(output.Items[0]["field_values"])) + } else { + assert.NotContains(t, output.Items[0], "field_values") + } + if protocol == inventory.ProtocolVersionMultiRoundTrip { + assert.JSONEq(t, getTextResult(t, result).Text, mustMarshalJSON(t, result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + } + } + }) + } + } + } +} + +func TestTypedCompatibilityRepositorySearchContract(t *testing.T) { + schema := SearchRepositories(translations.NullTranslationHelper).Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties["minimal_output"].Description, "modern clients receive additional curated repository details, not the complete GitHub API object") + assert.Contains(t, schema.Properties["minimal_output"].Description, "Legacy clients retain full GitHub API repository objects") +} + +func TestTypedCompatibilityGistRecoveryHandles(t *testing.T) { + var gist github.Gist + require.NoError(t, json.Unmarshal([]byte(gistBody), &gist)) + output := mustMarshalJSON(t, projectGist(&gist)) + assert.Contains(t, output, `"raw_url":"https://gist.githubusercontent.com/raw"`) + assert.Contains(t, output, `"git_pull_url":"https://gist.github.com/abc.git"`) +} + +func TestTypedCompatibilityReviewReactionParity(t *testing.T) { + tool := GranularAddPullRequestReviewCommentReaction(translations.NullTranslationHelper) + deps := BaseDeps{ + featureChecker: featureCheckerFor(inventory.FeatureFlag(FeatureFlagPullRequestsGranular)), + Client: mustNewGHClient(t, &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"id":77,"content":"heart"}`)) + })}}), + } + expected := `{"id":"77","url":"https://api.github.com/repos/owner/repo/pulls/comments/42/reactions/77"}` + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + session := connectTypedSearchClient(t, tool, deps, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"owner": "owner", "repo": "repo", "comment_id": 42, "content": "heart"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, expected, getTextResult(t, result).Text) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + assert.JSONEq(t, expected, mustMarshalJSON(t, result.StructuredContent)) + } + } +} + +func TestTypedCompatibilityDirectoryProjection(t *testing.T) { + tool := GetFileContents(translations.NullTranslationHelper) + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + session := connectTypedSearchClient(t, tool, typedRepositoryDeps(t), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"owner": "owner", "repo": "repo", "path": "directory", "fields": []string{"url", "git_url"}}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, `[{"url":"api-content-url","git_url":"api-git-url"}]`, getTextResult(t, result).Text) + } +} + +func TestTypedCompatibilityBlameIFC(t *testing.T) { + for _, private := range []bool{false, true} { + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + deps := typedRepositoryDeps(t) + deps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + deps.Client = mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{Private: &private}), + })) + session := connectTypedSearchClient(t, GetFileBlame(translations.NullTranslationHelper), deps, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_file_blame", Arguments: map[string]any{"owner": "owner", "repo": "repo", "path": "file"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, mustMarshalJSON(t, ifc.LabelCommitContents(private)), mustMarshalJSON(t, result.Meta["ifc"])) + } + } +} diff --git a/pkg/github/typed_consolidated_issue_enrichment_test.go b/pkg/github/typed_consolidated_issue_enrichment_test.go new file mode 100644 index 0000000000..2e02dcf09c --- /dev/null +++ b/pkg/github/typed_consolidated_issue_enrichment_test.go @@ -0,0 +1,100 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/google/go-github/v92/github" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTypedIssueReadIFCLabels(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + t.Run("protocol="+protocol, func(t *testing.T) { + deps := consolidatedIssueDeps(t) + deps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + session, schemas := consolidatedIssueSession(t, deps, protocol, false) + for _, method := range []string{"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"} { + args := map[string]any{"method": method, "owner": "owner", "repo": "repo", "issue_number": 1} + if method == "get_sub_issues" { + args["page"], args["perPage"] = 2, 5 + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "issue_read", Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError) + label := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", label["integrity"]) + assert.Equal(t, "public", label["confidentiality"]) + if schema := schemas["issue_read"]; schema != nil { + require.NotNil(t, result.StructuredContent) + require.NoError(t, schema.Validate(result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + } + } + }) + } +} + +func TestTypedIssueReadEnrichmentAndLockdown(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + for _, lockdown := range []bool{false, true} { + t.Run(fmt.Sprintf("protocol=%s/lockdown=%t", protocol, lockdown), func(t *testing.T) { + issue := &github.Issue{ + Number: new(1), NodeID: new("I_1"), Title: new("Child\u202e"), + State: new("open"), User: &github.User{Login: new("author")}, + IssueFieldValues: []*github.IssueFieldValue{{IssueFieldID: 1, Value: "must not leak REST fields"}}, + } + parent := map[string]any{ + "number": 7, "title": "Parent\u202e", "state": "OPEN", "url": "parent", + "author": map[string]any{"login": "unsafe"}, + "repository": map[string]any{"nameWithOwner": "owner/repo"}, + } + closing := map[string]any{ + "number": 8, "title": "Closing\u202e", "state": "OPEN", "url": "closing", + "author": map[string]any{"login": "unsafe"}, + "repository": map[string]any{"nameWithOwner": "owner/repo"}, + } + matcher := newIssueReadEnrichmentMatcher("I_1", githubv4mock.DataResponse(map[string]any{ + "nodes": []map[string]any{{ + "id": "I_1", "issueFieldValues": map[string]any{"nodes": []any{}}, + "parent": parent, "subIssuesSummary": map[string]any{"total": 2, "completed": 1, "percentCompleted": 50}, + "closedByPullRequestsReferences": map[string]any{"totalCount": 1, "nodes": []any{closing}}, + }}, + })) + rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, issue), + }) + deps := BaseDeps{ + Client: mustNewGHClient(t, rest), GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)), + RepoAccessCache: stubRepoAccessCache(mockRESTPermissionServer(t, "read", map[string]string{"author": "write"}), time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": lockdown}), + } + session, schemas := consolidatedIssueSession(t, deps, protocol, false) + text := `{"number":1,"title":"Child","state":"open","user":{"login":"author"},"assignees":[],"has_parent":true,"has_children":true,` + if !lockdown { + text += `"parent":{"number":7,"title":"Parent","state":"OPEN","url":"parent","repository":"owner/repo"},` + } + text += `"sub_issues_summary":{"total":2,"completed":1,"percent_completed":50},"closed_by_pull_requests":{"total_count":1,"references":[` + if !lockdown { + text += `{"number":8,"title":"Closing","state":"OPEN","url":"closing","repository":"owner/repo"}` + } + text += `]}}` + result := assertConsolidatedResult(t, session, schemas, "issue_read", map[string]any{ + "method": "get", "owner": "owner", "repo": "repo", "issue_number": 1, + }, text) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + assert.JSONEq(t, `{"method":"get","issue":`+text+`}`, mustMarshalJSON(t, result.StructuredContent)) + } + }) + } + } +} diff --git a/pkg/github/typed_consolidated_issue_outputs_test.go b/pkg/github/typed_consolidated_issue_outputs_test.go new file mode 100644 index 0000000000..60374e10cb --- /dev/null +++ b/pkg/github/typed_consolidated_issue_outputs_test.go @@ -0,0 +1,683 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "strings" + "testing" + "time" + + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/sanitize" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func consolidatedIssueSession(t *testing.T, deps BaseDeps, protocol string, ui bool) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + tools := []inventory.ServerTool{ + IssueRead(translations.NullTranslationHelper), IssueWrite(translations.NullTranslationHelper), + SubIssueWrite(translations.NullTranslationHelper), + } + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}). + WithFeatureChecker(featureCheckerFor()).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "consolidated-issues", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + if protocol == "" { + switch req := request.(type) { + case *mcp.ListToolsRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + case *mcp.CallToolRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + } + } + return next(ghcontext.WithUISupport(ctx, ui), method, request) + } + }) + version := protocol + if version == "" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + assert.Equal(t, "object", schema.Type, tool.Name) + assert.NotEmpty(t, schema.Properties["method"], tool.Name) + assertCompactIssueSchema(t, &schema) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + schemas[tool.Name] = resolved + } + return session, schemas +} + +func assertCompactIssueSchema(t *testing.T, schema *jsonschema.Schema) { + t.Helper() + if schema == nil { + return + } + for field, property := range schema.Properties { + if strings.HasSuffix(field, "_url") && field != "html_url" { + t.Errorf("hypermedia URL %q in compact issue output", field) + } + assert.NotEqual(t, "node_id", field) + assert.NotEqual(t, "issue_field_values", field) + assertCompactIssueSchema(t, property) + } + for _, definition := range schema.Definitions { + assertCompactIssueSchema(t, definition) + } + for _, definition := range schema.Defs { + assertCompactIssueSchema(t, definition) + } + assertCompactIssueSchema(t, schema.Items) + for _, variants := range [][]*jsonschema.Schema{schema.OneOf, schema.AnyOf, schema.AllOf} { + for _, variant := range variants { + assertCompactIssueSchema(t, variant) + } + } +} + +func TestCompactIssueOutputSchemasAreCached(t *testing.T) { + assert.Same(t, issueReadOutputSchema(), issueReadOutputSchema()) + assert.Same(t, issueWriteOutputSchema(), issueWriteOutputSchema()) + assert.Same(t, subIssueWriteOutputSchema(), subIssueWriteOutputSchema()) +} + +func TestForbiddenIssueVariantPropertySchema(t *testing.T) { + schema := forbiddenIssueVariantProperty() + assert.JSONEq(t, `{"not":{"description":"Any value."}}`, mustMarshalJSON(t, schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + for _, value := range []any{nil, "", float64(0), false, []any{}, map[string]any{}} { + assert.Error(t, resolved.Validate(value), "%v", value) + } +} + +func assertConsolidatedResult(t *testing.T, session *mcp.ClientSession, schemas map[string]*jsonschema.Resolved, name string, args map[string]any, text string) *mcp.CallToolResult { + t.Helper() + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, "%s: %s", name, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + if schema := schemas[name]; schema != nil { + // Nullable method data must still have a present object envelope. + require.NotNil(t, result.StructuredContent) + assert.JSONEq(t, mustMarshalJSON(t, result.StructuredContent), getTextResult(t, result).Text, + "modern text must serialize the same compact DTO as structuredContent") + var value any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &value)) + require.NoError(t, schema.Validate(value)) + } else { + assert.Equal(t, text, getTextResult(t, result).Text, "legacy text must preserve the original formatter output") + assert.Nil(t, result.StructuredContent) + } + return result +} + +func consolidatedIssueDeps(t *testing.T) BaseDeps { + t.Helper() + rest := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/comments") { + assert.Equal(t, "1", r.URL.Query().Get("page")) + assert.Equal(t, "30", r.URL.Query().Get("per_page")) + switch { + case strings.Contains(r.URL.Path, "/2/"): + _, _ = w.Write([]byte(`[]`)) + case strings.Contains(r.URL.Path, "/3/"): + _, _ = w.Write([]byte(`null`)) + default: + _, _ = w.Write([]byte(`[{"id":42,"body":"hello","html_url":"https://github.com/owner/repo/issues/1#issuecomment-42"}]`)) + } + return + } + if strings.Contains(r.URL.Path, "/sub_issue") { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/1/") { + assert.Equal(t, "2", r.URL.Query().Get("page")) + assert.Equal(t, "5", r.URL.Query().Get("per_page")) + } + switch { + case strings.Contains(r.URL.Path, "/2/"): + _, _ = w.Write([]byte(`[]`)) + return + case strings.Contains(r.URL.Path, "/3/"): + _, _ = w.Write([]byte(`null`)) + return + case strings.Contains(r.URL.Path, "/4/"): + _, _ = w.Write([]byte(`[null]`)) + return + } + if r.Method == http.MethodPost && !strings.HasSuffix(r.URL.Path, "/priority") { + w.WriteHeader(http.StatusCreated) + } + text := `{"id":7,"number":7,"title":"Child","body":"body","created_at":"2026-01-01T00:00:00Z","issue_field_values":[{"issue_field_id":11,"node_id":"IF_11","data_type":"number","value":0}]}` + if r.Method == http.MethodGet { + text = "[" + text + "]" + } + _, _ = w.Write([]byte(text)) + return + } + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusCreated) + } + _, _ = w.Write([]byte(`{"id":123,"number":1,"title":"Subject","state":"open","html_url":"https://github.com/owner/repo/issues/1","assignees":[],"labels":[]}`)) + })}} + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Query string `json:"query"` + Variables struct { + IssueNumber int `json:"issueNumber"` + Input struct { + IssueID string `json:"issueId"` + StateReason string `json:"stateReason"` + DuplicateIssueID string `json:"duplicateIssueId"` + } `json:"input"` + } `json:"variables"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + switch { + case strings.Contains(req.Query, "createIssue("): + _, _ = w.Write([]byte(`{"data":{"createIssue":{"issue":{"fullDatabaseId":"123","url":"https://github.com/owner/repo/issues/1"}}}}`)) + case strings.Contains(req.Query, "parentRepository:"): + _, _ = w.Write([]byte(`{"data":{"childRepository":{"id":"R_1","nameWithOwner":"owner/repo"},"parentRepository":{"issue":{"id":"I_parent","number":7}}}}`)) + case strings.Contains(req.Query, "parent{"): + parent := `{"number":7,"title":"Parent","state":"OPEN","url":"https://github.com/owner/repo/issues/7","repository":{"nameWithOwner":"owner/repo"},"author":{"login":"octocat"}}` + if req.Variables.IssueNumber == 2 { + parent = "null" + } + _, _ = w.Write([]byte(`{"data":{"repository":{"issue":{"parent":` + parent + `}}}}`)) + case strings.Contains(req.Query, "labels("): + labels := `{"nodes":[{"id":"L_1","name":"bug","color":"red","description":"Bug"}],"totalCount":1}` + if req.Variables.IssueNumber == 2 { + labels = `{"nodes":[],"totalCount":0}` + } + _, _ = w.Write([]byte(`{"data":{"repository":{"issue":{"labels":` + labels + `}}}}`)) + case strings.Contains(req.Query, "reopenIssue("): + assert.Equal(t, "I_1", req.Variables.Input.IssueID) + _, _ = w.Write([]byte(`{"data":{"reopenIssue":{"issue":{"id":"I_1","number":1,"url":"issue","state":"OPEN"}}}}`)) + case strings.Contains(req.Query, "closeIssue("): + assert.Equal(t, "I_1", req.Variables.Input.IssueID) + assert.Contains(t, []string{"COMPLETED", "NOT_PLANNED", "DUPLICATE"}, req.Variables.Input.StateReason) + if req.Variables.Input.StateReason == "DUPLICATE" { + assert.Equal(t, "I_2", req.Variables.Input.DuplicateIssueID) + } + _, _ = w.Write([]byte(`{"data":{"closeIssue":{"issue":{"id":"I_1","number":1,"url":"issue","state":"CLOSED"}}}}`)) + case strings.Contains(req.Query, "issue(number: $issueNumber)"): + if strings.Contains(req.Query, "duplicateIssue:") { + _, _ = w.Write([]byte(`{"data":{"repository":{"issue":{"id":"I_1"},"duplicateIssue":{"id":"I_2"}}}}`)) + } else { + _, _ = w.Write([]byte(`{"data":{"repository":{"issue":{"id":"I_1"}}}}`)) + } + default: + t.Errorf("unexpected GraphQL query: %s", req.Query) + w.WriteHeader(http.StatusInternalServerError) + } + })}} + return BaseDeps{ + Client: mustNewGHClient(t, rest), GQLClient: githubv4.NewClient(gql), + RepoAccessCache: stubRepoAccessCache(nil, time.Minute), + } +} + +func TestTypedConsolidatedIssueOutputs(t *testing.T) { + const mutationText = `{"id":"123","url":"https://github.com/owner/repo/issues/1"}` + const subText = `{"id":7,"number":7,"title":"Child","body":"body","created_at":"2026-01-01T00:00:00Z","issue_field_values":[{"issue_field_id":11,"node_id":"IF_11","data_type":"number","value":0}]}` + const subStructured = `{"id":7,"number":7,"title":"Child","body":"body","created_at":"2026-01-01T00:00:00Z","assignees":[],"field_values":[{"issue_field_id":11,"data_type":"number","value":0}]}` + cases := []struct { + tool string + args map[string]any + text string + }{ + {"issue_read", map[string]any{"method": "get", "issue_number": "1.0"}, `{"number":1,"title":"Subject","state":"open","html_url":"https://github.com/owner/repo/issues/1","assignees":[]}`}, + {"issue_read", map[string]any{"method": "get_comments", "issue_number": "1", "page": "0", "perPage": "0"}, `[{"id":42,"body":"hello","html_url":"https://github.com/owner/repo/issues/1#issuecomment-42"}]`}, + {"issue_read", map[string]any{"method": "get_comments", "issue_number": 2}, `[]`}, + {"issue_read", map[string]any{"method": "get_comments", "issue_number": 3}, `[]`}, + {"issue_read", map[string]any{"method": "get_sub_issues", "issue_number": "1", "page": "2", "perPage": "5"}, `[` + subText + `]`}, + {"issue_read", map[string]any{"method": "get_sub_issues", "issue_number": 2}, `[]`}, + {"issue_read", map[string]any{"method": "get_sub_issues", "issue_number": 3}, `null`}, + {"issue_read", map[string]any{"method": "get_sub_issues", "issue_number": 4}, `[null]`}, + {"issue_read", map[string]any{"method": "get_parent", "issue_number": "1"}, `{"parent":{"number":7,"repository":"owner/repo","state":"OPEN","title":"Parent","url":"https://github.com/owner/repo/issues/7"}}`}, + {"issue_read", map[string]any{"method": "get_parent", "issue_number": 2}, `{"parent":null}`}, + {"issue_read", map[string]any{"method": "get_labels", "issue_number": 1}, `{"labels":[{"color":"red","description":"Bug","id":"L_1","name":"bug"}],"totalCount":1}`}, + {"issue_read", map[string]any{"method": "get_labels", "issue_number": 2}, `{"labels":[],"totalCount":0}`}, + {"issue_write", map[string]any{"method": "create", "title": "Subject"}, mutationText}, + {"issue_write", map[string]any{"method": "update", "issue_number": "1.0"}, mutationText}, + {"issue_write", map[string]any{"method": "update", "issue_number": "1", "state": "open"}, mutationText}, + {"issue_write", map[string]any{"method": "update", "issue_number": 1, "state": "closed", "state_reason": "completed"}, mutationText}, + {"issue_write", map[string]any{"method": "update", "issue_number": 1, "state": "closed", "state_reason": "not_planned"}, mutationText}, + {"issue_write", map[string]any{"method": "update", "issue_number": 1, "state": "closed", "state_reason": "duplicate", "duplicate_of": "2"}, mutationText}, + {"issue_write", map[string]any{"method": "create", "title": "Subject", "parent_issue_number": "7"}, mutationText}, + {"sub_issue_write", map[string]any{"method": "ADD", "issue_number": "1", "sub_issue_id": "7", "replace_parent": true}, subText}, + {"sub_issue_write", map[string]any{"method": "remove", "issue_number": 1, "sub_issue_id": 7}, subText}, + {"sub_issue_write", map[string]any{"method": "reprioritize", "issue_number": 1, "sub_issue_id": 7, "after_id": "8.0"}, subText}, + {"sub_issue_write", map[string]any{"method": "reprioritize", "issue_number": 1, "sub_issue_id": 7, "before_id": "8"}, subText}, + } + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + t.Run("protocol="+protocol, func(t *testing.T) { + session, schemas := consolidatedIssueSession(t, consolidatedIssueDeps(t), protocol, false) + for i, tc := range cases { + t.Run(tc.tool+"/"+tc.args["method"].(string)+"/"+string(rune('A'+i)), func(t *testing.T) { + args := map[string]any{"owner": "owner", "repo": "repo"} + maps.Copy(args, tc.args) + result := assertConsolidatedResult(t, session, schemas, tc.tool, args, tc.text) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + want := tc.text + switch want { + case subText: + want = subStructured + case "[" + subText + "]": + want = "[" + subStructured + "]" + } + method := strings.ToLower(tc.args["method"].(string)) + field := "issue" + switch method { + case "get_comments": + field = "comments" + case "get_sub_issues": + field = "sub_issues" + case "get_parent": + field = "parent" + case "get_labels": + field = "labels" + } + if method == "get_parent" || method == "get_labels" { + want = `{"method":"` + method + `",` + strings.TrimPrefix(want, "{") + } else { + want = `{"method":"` + method + `","` + field + `":` + want + `}` + } + assert.JSONEq(t, want, mustMarshalJSON(t, result.StructuredContent)) + } + }) + } + for _, tc := range []struct { + tool string + args map[string]any + text string + }{ + {"issue_read", map[string]any{}, "missing required parameter: method"}, + {"issue_read", map[string]any{"method": "get", "issue_number": 0}, "missing required parameter: issue_number"}, + {"issue_read", map[string]any{"method": "get", "issue_number": "1.5"}, "non-integer numeric value"}, + {"issue_read", map[string]any{"method": "GET", "issue_number": 1}, "get"}, + {"issue_read", map[string]any{"method": "get", "issue_number": 1, "page": nil}, "not a valid number"}, + {"issue_write", map[string]any{"method": "create"}, "missing required parameter: title"}, + {"issue_write", map[string]any{"method": "update"}, "missing required parameter: issue_number"}, + {"issue_write", map[string]any{"method": "update", "issue_number": 1, "type": ""}, "must not be empty"}, + {"issue_write", map[string]any{"method": "update", "issue_number": 1, "labels": 42}, "[]string"}, + {"issue_write", map[string]any{"method": "create", "parent_issue_number": 0}, "greater than 0"}, + {"issue_write", map[string]any{"method": "update", "issue_number": 1, "duplicate_of": 2}, "duplicate_of can only"}, + {"issue_write", map[string]any{"method": "create", "issue_fields": []any{map[string]any{"field_name": "Priority", "value": nil}}}, "value cannot be null"}, + {"sub_issue_write", map[string]any{"method": "add", "issue_number": 1}, "missing required parameter: sub_issue_id"}, + {"sub_issue_write", map[string]any{"method": "reprioritize", "issue_number": 1, "sub_issue_id": 7}, "either after_id or before_id"}, + {"sub_issue_write", map[string]any{"method": "reprioritize", "issue_number": 1, "sub_issue_id": 7, "after_id": 8, "before_id": 9}, "only one"}, + } { + args := map[string]any{"owner": "owner", "repo": "repo"} + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool, Arguments: args}) + require.NoError(t, err) + require.True(t, result.IsError, "%s: %s", tc.tool, mustMarshalJSON(t, result)) + assert.Contains(t, getErrorResult(t, result).Text, tc.text) + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedIssueWriteAwaitingForm(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + t.Run("protocol="+protocol, func(t *testing.T) { + session, schemas := consolidatedIssueSession(t, consolidatedIssueDeps(t), protocol, true) + for _, method := range []string{"create", "update"} { + args := map[string]any{"method": method, "owner": "owner", "repo": "repo", "issue_number": "1", "title": "Subject"} + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "issue_write", Arguments: args}) + require.NoError(t, err) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + assert.Contains(t, getErrorResult(t, result).Text, "interactive form has been shown") + assert.Contains(t, getErrorResult(t, result).Text, "NOT been") + if protocol == inventory.ProtocolVersionMultiRoundTrip { + require.NotNil(t, result.StructuredContent) + assert.JSONEq(t, `{"status":"awaiting_user_submission","reason":"An interactive form is being shown to the user. The operation has not been performed."}`, mustMarshalJSON(t, result.StructuredContent)) + require.NoError(t, schemas["issue_write"].Validate(result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + } + args["_ui_submitted"] = true + assertConsolidatedResult(t, session, schemas, "issue_write", args, `{"id":"123","url":"https://github.com/owner/repo/issues/1"}`) + } + }) + } +} + +func TestTypedConsolidatedIssueAPIErrors(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + t.Run("protocol="+protocol, func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client), GQLClient: githubv4.NewClient(client)} + session, _ := consolidatedIssueSession(t, deps, protocol, false) + for tool, methods := range map[string][]string{ + "issue_read": {"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"}, + "issue_write": {"create", "update"}, "sub_issue_write": {"add", "remove", "reprioritize"}, + } { + for _, method := range methods { + args := map[string]any{ + "method": method, "owner": "owner", "repo": "repo", "issue_number": 1, + "title": "Subject", "sub_issue_id": 7, "after_id": 8, + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool, Arguments: args}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent, tool+"/"+method) + assert.Contains(t, getErrorResult(t, result).Text, "Forbidden") + } + } + }) + } +} + +func TestTypedConsolidatedNullMutationResponses(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + for _, payload := range []string{`{}`, `null`} { + t.Run("protocol="+protocol+"/payload="+payload, func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && !strings.HasSuffix(r.URL.Path, "/priority") { + w.WriteHeader(http.StatusCreated) + } + _, _ = w.Write([]byte(payload)) + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client), GQLClient: githubv4.NewClient(client)} + session, schemas := consolidatedIssueSession(t, deps, protocol, false) + for _, method := range []string{"create", "update"} { + assertConsolidatedResult(t, session, schemas, "issue_write", map[string]any{ + "method": method, "owner": "owner", "repo": "repo", "issue_number": 1, "title": "Subject", + }, `{"id":"0","url":""}`) + } + for _, method := range []string{"add", "remove", "reprioritize"} { + result := assertConsolidatedResult(t, session, schemas, "sub_issue_write", map[string]any{ + "method": method, "owner": "owner", "repo": "repo", "issue_number": 1, "sub_issue_id": 7, "after_id": 8, + }, payload) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + data := `{"assignees":[]}` + if payload == "null" { + data = "null" + } + assert.JSONEq(t, `{"method":"`+method+`","issue":`+data+`}`, mustMarshalJSON(t, result.StructuredContent)) + } + } + }) + } + } +} + +func TestConsolidatedIssueStrictSchemasAndScalars(t *testing.T) { + fieldSchema := *IssueWrite(translations.NullTranslationHelper).Tool.InputSchema.(*jsonschema.Schema).Properties["issue_fields"].Items + fieldSchema.OneOf = issueWriteFieldVariants() + for _, tc := range []struct { + schema *jsonschema.Schema + valid []string + invalid []string + }{ + { + issueReadOutputSchema(), + []string{`{"method":"get","issue":null}`, `{"method":"get_comments","comments":[]}`, `{"method":"get_sub_issues","sub_issues":[null]}`, `{"method":"get_parent","parent":null}`, `{"method":"get","issue":{"number":0,"title":"","state":"","assignees":[]}}`, `{"method":"get_labels","labels":[],"totalCount":0}`}, + []string{`null`, `[]`, `{}`, `{"method":"get"}`, `{"method":"get","comments":[]}`, `{"method":"get","issue":{"number":1}}`, `{"method":"get_parent","parent":{"number":1}}`, `{"method":"get_labels","labels":[]}`, `{"method":"get","issue":null,"comments":[]}`}, + }, + { + issueWriteOutputSchema(), + []string{`{"method":"create","issue":{"id":"","url":""}}`, `{"status":"awaiting_user_submission","reason":"wait"}`}, + []string{`{}`, `{"method":"create","issue":{"id":"1"}}`, `{"status":"created","reason":"wait"}`, `{"status":"awaiting_user_submission"}`, `{"method":"create","issue":{"id":"1","url":"url"},"status":"awaiting_user_submission","reason":"wait"}`}, + }, + { + subIssueWriteOutputSchema(), + []string{ + `{"method":"add","issue":null}`, + `{"method":"remove","issue":{"assignees":[]}}`, + `{"method":"reprioritize","issue":{"state":"closed","state_reason":"duplicate","assignees":[]}}`, + `{"method":"add","issue":{"assignees":[],"field_values":[null,{"issue_field_id":1,"data_type":"date","value":"2026-01-01"}]}}`, + }, + []string{ + `null`, `{}`, `{"method":"add"}`, `{"method":"get","issue":null}`, + `{"method":"add","issue":{"assignees":[],"state":"OPEN"}}`, + `{"method":"add","issue":{"assignees":[],"state_reason":"unknown"}}`, + `{"method":"add","issue":{"assignees":[],"url":"api"}}`, + `{"method":"add","issue":{"assignees":[],"field_values":[{"issue_field_id":1,"data_type":"unknown","value":1}]}}`, + `{"method":"add","issue":{"assignees":[],"field_values":[{"issue_field_id":1,"data_type":"number","value":{}}]}}`, + }, + }, + { + &fieldSchema, + []string{`{"field_name":"Text","value":""}`, `{"field_name":"Number","value":0,"delete":false}`, `{"field_name":"Date","value":"2026-01-01"}`, `{"field_name":"Priority","field_option_name":"High"}`, `{"field_name":"Text","delete":true}`}, + []string{`{"field_name":"Text"}`, `{"field_name":"Text","value":null}`, `{"field_name":"Text","value":1,"delete":true}`, `{"field_name":"Text","value":1,"field_option_name":"High"}`, `{"field_name":"Text","delete":false}`, `{"value":1}`}, + }, + } { + resolved, err := tc.schema.Resolve(nil) + require.NoError(t, err) + for _, raw := range tc.valid { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + require.NoError(t, resolved.Validate(value), raw) + } + for _, raw := range tc.invalid { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + require.Error(t, resolved.Validate(value), raw) + } + } + for _, raw := range []string{`""`, `"2026-01-01"`, `"High"`, `0`, `1.5`, `false`, `null`} { + var value IssueFieldValue + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + assert.JSONEq(t, raw, mustMarshalJSON(t, value)) + } + var value IssueFieldValue + require.Error(t, json.Unmarshal([]byte(`{}`), &value)) + require.Error(t, json.Unmarshal([]byte(`[]`), &value)) +} + +func TestSubIssueOutputProjectsCompleteAPIResponse(t *testing.T) { + // Every exported API field is populated, including false/zero pointers, + // nullable collection elements and all custom-field scalar variants. + var issue github.SubIssue + require.NoError(t, json.Unmarshal([]byte(`{ + "id":1,"number":1,"state":"open","state_reason":"reopened","locked":false, + "title":"Child\u202e","body":"body\u202e","author_association":"OWNER", + "user":{"login":"octo","id":1},"labels":[null,{"name":"bug","id":2}], + "assignee":{"login":"octo"},"comments":0,"closed_at":"2026-01-01T00:00:00Z", + "created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z", + "closed_by":{"login":"octo"},"url":"api","html_url":"html","comments_url":"comments", + "events_url":"events","labels_url":"labels","repository_url":"repo","parent_issue_url":"parent", + "milestone":{"title":"v1","number":1},"pull_request":{"url":"pr"}, + "repository":{"id":1,"name":"repo","owner":{"login":"octo"},"topics":["topic"]}, + "reactions":{"total_count":0},"assignees":[null,{"login":"octo"}], + "node_id":"I_1","draft":false,"type":{"id":1,"name":"Bug"}, + "pinned_comment":{"id":1,"body":"comment"},"performed_via_github_app":{"id":1,"permissions":{"issues":"write"}}, + "issue_dependencies_summary":{"blocked_by":0,"blocking":1}, + "sub_issues_summary":{"total":1,"completed":0,"percent_completed":0}, + "issue_field_values":[null, + {"issue_field_id":1,"node_id":"IF_1","data_type":"text","value":""}, + {"issue_field_id":2,"node_id":"IF_2","data_type":"number","value":0}, + {"issue_field_id":3,"node_id":"IF_3","data_type":"date","value":"2026-01-01"}, + {"issue_field_id":4,"node_id":"IF_4","data_type":"single_select","value":"High","single_select_option":{"id":1,"name":"High","color":"red"}}, + {"issue_field_id":5,"node_id":"IF_5","data_type":"text","value":null} + ], + "text_matches":[{"fragment":"match","matches":[{"text":"match","indices":[0,5]}]}], + "active_lock_reason":"resolved" + }`), &issue)) + sanitizeSubIssueTitleAndBody(&issue) + out, err := subIssueOutput(&issue) + require.NoError(t, err) + assert.Equal(t, sanitize.PlainText("Child\u202e"), *out.Title) + assert.JSONEq(t, `{ + "id":1,"number":1,"title":"Child","body":"body","state":"open","state_reason":"reopened", + "html_url":"html","user":{"login":"octo","id":1},"labels":["bug"],"assignees":["octo"], + "milestone":"v1","comments":0,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","closed_at":"2026-01-01T00:00:00Z", + "issue_type":"Bug","sub_issues_summary":{"total":1,"completed":0,"percent_completed":0}, + "field_values":[null,{"issue_field_id":1,"data_type":"text","value":""},{"issue_field_id":2,"data_type":"number","value":0}, + {"issue_field_id":3,"data_type":"date","value":"2026-01-01"},{"issue_field_id":4,"data_type":"single_select","value":"High"}, + {"issue_field_id":5,"data_type":"text","value":null}] + }`, mustMarshalJSON(t, out)) + resolved, err := subIssueSchema().Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, out)), &value)) + require.NoError(t, resolved.Validate(value)) +} + +func TestTypedIssueCommentsCompactUsersAndLockdown(t *testing.T) { + const comment = `{"id":42,"body":"hello\u202e","html_url":"comment","url":"api-comment","node_id":"IC_42","user":{"login":"author","id":7,"html_url":"profile","avatar_url":"avatar"}}` + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + for _, lockdown := range []bool{false, true} { + t.Run(fmt.Sprintf("protocol=%s/lockdown=%t", protocol, lockdown), func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[` + comment + `,{"id":43,"body":"restricted","html_url":"unsafe","user":{"login":"unsafe"}}]`)) + })}} + deps := BaseDeps{ + Client: mustNewGHClient(t, client), GQLClient: githubv4.NewClient(client), + RepoAccessCache: stubRepoAccessCache(mockRESTPermissionServer(t, "read", map[string]string{"author": "write"}), time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": lockdown}), + } + session, schemas := consolidatedIssueSession(t, deps, protocol, false) + text := `[{"id":42,"body":"hello","html_url":"comment","user":{"login":"author","id":7,"profile_url":"profile","avatar_url":"avatar"}}` + projected := `{"method":"get_comments","comments":[{"id":42,"body":"hello","html_url":"comment","user":{"login":"author","id":7}}` + if !lockdown { + unsafe := `,{"id":43,"body":"restricted","html_url":"unsafe","user":{"login":"unsafe"}}` + text += unsafe + projected += unsafe + } + result := assertConsolidatedResult(t, session, schemas, "issue_read", map[string]any{ + "method": "get_comments", "owner": "owner", "repo": "repo", "issue_number": 1, + }, text+`]`) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + assert.JSONEq(t, projected+`]}`, mustMarshalJSON(t, result.StructuredContent)) + } + }) + } + } +} + +func TestTypedIssueWriteMutationOptionalityAndFields(t *testing.T) { + const resultText = `{"id":"123","url":"https://github.com/owner/repo/issues/1"}` + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + t.Run("protocol="+protocol, func(t *testing.T) { + var requestBody string + var deleted []string + rest := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + deleted = append(deleted, r.URL.Path) + w.WriteHeader(http.StatusNoContent) + return + } + + var body json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + requestBody = string(body) + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusCreated) + } + var sent struct { + Labels []string `json:"labels"` + } + require.NoError(t, json.Unmarshal(body, &sent)) + response := &github.Issue{ID: new(int64(123)), HTMLURL: new("https://github.com/owner/repo/issues/1")} + for _, label := range sent.Labels { + response.Labels = append(response.Labels, &github.Label{Name: label}) + } + require.NoError(t, json.NewEncoder(w).Encode(response)) + })}} + existing := `[{"__typename":"IssueFieldTextValue","field":{"fullDatabaseId":"1","name":"Text"},"value":"old"},{"__typename":"IssueFieldDateValue","field":{"fullDatabaseId":"3","name":"Date"},"value":"2026-01-01"}]` + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Query string `json:"query"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + switch { + case strings.Contains(request.Query, "issueFields("): + _, _ = w.Write([]byte(`{"data":{"repository":{"issueFields":{"nodes":[ + {"__typename":"IssueFieldText","fullDatabaseId":"1","name":"Text","dataType":"TEXT"}, + {"__typename":"IssueFieldNumber","fullDatabaseId":"2","name":"Number","dataType":"NUMBER"}, + {"__typename":"IssueFieldDate","fullDatabaseId":"3","name":"Date","dataType":"DATE"}, + {"__typename":"IssueFieldSingleSelect","fullDatabaseId":"4","name":"Priority","dataType":"SINGLE_SELECT","options":[{"fullDatabaseId":"40","name":"High"}]} + ]}}}}`)) + case strings.Contains(request.Query, "issueFieldValues("): + _, _ = w.Write([]byte(`{"data":{"repository":{"issue":{"issueFieldValues":{"nodes":` + existing + `}}}}}`)) + default: + t.Errorf("unexpected GraphQL mutation/query: %s", request.Query) + } + })}} + deps := BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: githubv4.NewClient(gql)} + session, schemas := consolidatedIssueSession(t, deps, protocol, false) + cases := []struct { + args map[string]any + body string + }{ + {map[string]any{}, `{}`}, + {map[string]any{"labels": []string{}, "assignees": []string{}, "type": nil, "milestone": "0"}, `{"labels":[],"assignees":[],"type":null}`}, + {map[string]any{"labels": nil, "assignees": nil}, `{}`}, + {map[string]any{"labels": []string{"bug"}, "assignees": []string{"octo"}, "type": "Bug", "milestone": "2.0"}, `{"labels":["bug"],"assignees":["octo"],"type":"Bug","milestone":2}`}, + {map[string]any{"issue_fields": []any{ + map[string]any{"field_name": "Number", "value": 0}, + map[string]any{"field_name": "Text", "delete": true}, + }}, `{"issue_field_values":[{"field_id":2,"value":0},{"field_id":3,"value":"2026-01-01"}]}`}, + {map[string]any{"method": "create", "title": "Subject", "body": "", "issue_fields": []any{ + map[string]any{"field_name": " text ", "value": ""}, + map[string]any{"field_name": "Number", "value": 0}, + map[string]any{"field_name": "Date", "value": "2026-01-01"}, + map[string]any{"field_name": "Priority", "field_option_name": " high "}, + }}, `{"title":"Subject","body":"","issue_field_values":[{"field_id":1,"value":""},{"field_id":2,"value":0},{"field_id":3,"value":"2026-01-01"},{"field_id":4,"value":"High"}]}`}, + {map[string]any{"method": "create", "title": "Subject", "issue_fields": []any{ + map[string]any{"field_name": "Text", "delete": true}, + }}, `{"title":"Subject","body":""}`}, + } + for _, tc := range cases { + args := map[string]any{"method": "update", "owner": "owner", "repo": "repo", "issue_number": "1"} + maps.Copy(args, tc.args) + assertConsolidatedResult(t, session, schemas, "issue_write", args, resultText) + assert.JSONEq(t, tc.body, requestBody) + } + existing = `[{"__typename":"IssueFieldTextValue","field":{"fullDatabaseId":"1","name":"Text"},"value":"old"}]` + assertConsolidatedResult(t, session, schemas, "issue_write", map[string]any{ + "method": "update", "owner": "owner", "repo": "repo", "issue_number": 1, + "issue_fields": []any{map[string]any{"field_name": "Text", "delete": true}}, + }, resultText) + assert.JSONEq(t, `{}`, requestBody) + assert.Equal(t, []string{"/repos/owner/repo/issues/1/issue-field-values/1"}, deleted) + for _, fields := range []any{ + []any{map[string]any{"field_name": "Missing", "value": "value"}}, + []any{map[string]any{"field_name": "Text", "field_option_name": "High"}}, + []any{map[string]any{"field_name": "Priority", "field_option_name": "Missing"}}, + } { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "issue_write", Arguments: map[string]any{ + "method": "create", "owner": "owner", "repo": "repo", "title": "Subject", "issue_fields": fields, + }}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + assert.Contains(t, getErrorResult(t, result).Text, "failed to resolve issue_fields") + } + }) + } +} diff --git a/pkg/github/typed_consolidated_pull_request_outputs_test.go b/pkg/github/typed_consolidated_pull_request_outputs_test.go new file mode 100644 index 0000000000..60afd2f1f1 --- /dev/null +++ b/pkg/github/typed_consolidated_pull_request_outputs_test.go @@ -0,0 +1,634 @@ +package github + +import ( + "context" + "encoding/json" + "maps" + "net/http" + "reflect" + "slices" + "strings" + "testing" + "time" + + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var typedPullRequestProtocols = []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} + +func consolidatedPullRequestSession(t *testing.T, deps BaseDeps, protocol string, ui bool, flags ...inventory.FeatureFlag) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + tr := translations.NullTranslationHelper + tools := []inventory.ServerTool{ + PullRequestRead(tr), CreatePullRequest(tr), UpdatePullRequest(tr), MergePullRequest(tr), + UpdatePullRequestBranch(tr), PullRequestReviewWrite(tr), PullRequestReviewWriteWithResolutionReason(tr), + AddCommentToPendingReview(tr), AddReplyToPullRequestComment(tr), + } + canonicalSchemas := make(map[string]string) + for _, tool := range tools { + if tool.Tool.Name == "pull_request_review_write" { + continue + } + canonicalSchemas[tool.Tool.Name] = mustMarshalJSON(t, tool.Tool.OutputSchema) + } + review := PullRequestReviewWrite(tr) + if slices.Contains(flags, FeatureFlagThreadResolutionReason) { + review = PullRequestReviewWriteWithResolutionReason(tr) + require.NoError(t, toolsnaps.Test("pull_request_review_write_resolution_reason", review.Tool)) + } + canonicalSchemas[review.Tool.Name] = mustMarshalJSON(t, review.Tool.OutputSchema) + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}). + WithFeatureChecker(featureCheckerFor(flags...)).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "consolidated-pull-requests", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + if protocol == "" { + switch req := request.(type) { + case *mcp.ListToolsRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + case *mcp.CallToolRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + } + } + result, err := next(ghcontext.WithUISupport(ctx, ui), method, request) + if protocol == inventory.ProtocolVersionMultiRoundTrip && method == "tools/call" && err == nil { + if call, ok := result.(*mcp.CallToolResult); ok && !call.IsError && len(call.Content) == 1 { + if text, ok := call.Content[0].(*mcp.TextContent); ok && text.Text == "null" { + // The client decodes explicit null and an omitted field as nil. + // Check the serialized response to distinguish them. + var wire map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, call)), &wire)) + structured, present := wire["structuredContent"] + assert.True(t, present, "successful nullable output must include structuredContent") + assert.Equal(t, "null", string(structured)) + } + } + } + return result, err + } + }) + version := protocol + if version == "" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + // Exactly one pull_request_review_write variant is exposed for a feature set. + require.Len(t, list.Tools, len(tools)-1) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, tool.Name) + assert.JSONEq(t, canonicalSchemas[tool.Name], mustMarshalJSON(t, tool.OutputSchema), tool.Name) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + schemas[tool.Name] = resolved + } + return session, schemas +} + +const ( + typedPRJSON = `{"number":1,"title":"Subject","body":"body","state":"open","draft":false,"merged":false,"mergeable_state":"clean","html_url":"https://github.com/owner/repo/pull/1","user":{"login":"octocat"},"head":{"ref":"feature","sha":"abc"},"base":{"ref":"main","sha":"def"},"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}` + typedPRDiff = "diff --git a/file b/file\n+added\n" + typedPRReviewThreads = `{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[{"id":"T_1","isResolved":false,"isOutdated":false,"isCollapsed":false,"comments":{"nodes":[{"id":"C_1","body":"nit","path":"file","line":3,"originalLine":3,"startLine":null,"originalStartLine":null,"author":{"login":"octocat"},"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z","url":"https://github.com/owner/repo/pull/1#discussion_r1"}],"totalCount":1}}],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"startCursor":"s","endCursor":"e"},"totalCount":1}}}}}` +) + +func consolidatedPullRequestDeps(t *testing.T) BaseDeps { + t.Helper() + rest := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/v3") + write := func(status int, body string) { + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + } + switch { + case path == "/repos/owner/repo/pulls/1" && strings.Contains(r.Header.Get("Accept"), "diff"): + write(http.StatusOK, typedPRDiff) + case path == "/repos/owner/repo/pulls/1" || path == "/repos/owner/repo/pulls" && r.Method == http.MethodPost: + status := http.StatusOK + if r.Method == http.MethodPost { + status = http.StatusCreated + } + write(status, typedPRJSON) + case path == "/repos/owner/repo/pulls/2": + // A sparse PR without a head SHA still yields a valid status payload. + write(http.StatusOK, `{"number":2,"head":{"sha":"abc"}}`) + case path == "/repos/owner/repo/pulls/3": + // A successful null API payload is normalized to the legacy zero-value PR. + write(http.StatusOK, `null`) + case path == "/repos/owner/repo/commits/abc/status": + write(http.StatusOK, `{"state":"success","sha":"abc","total_count":1,"statuses":[{"state":"success","context":"ci","target_url":"https://ci"}]}`) + case path == "/repos/owner/repo/commits/abc/check-runs": + if r.URL.Query().Get("page") == "2" { + write(http.StatusOK, `{"total_count":0,"check_runs":[]}`) + } else { + write(http.StatusOK, `{"total_count":1,"check_runs":[{"id":5,"name":"build","status":"completed","conclusion":"success"}]}`) + } + case path == "/repos/owner/repo/pulls/1/files": + write(http.StatusOK, `[{"filename":"file","status":"modified","additions":1,"changes":1,"patch":"+added"}]`) + case path == "/repos/owner/repo/pulls/2/files", path == "/repos/owner/repo/pulls/2/commits", + path == "/repos/owner/repo/pulls/2/reviews", path == "/repos/owner/repo/issues/2/comments": + write(http.StatusOK, `[]`) + case path == "/repos/owner/repo/pulls/1/commits": + write(http.StatusOK, `[{"sha":"abc","html_url":"https://github.com/owner/repo/commit/abc","commit":{"message":"msg","author":{"name":"Octo","email":"octo@example.com","date":"2026-01-01T00:00:00Z"}}}]`) + case path == "/repos/owner/repo/pulls/1/reviews": + write(http.StatusOK, `[{"id":9,"state":"APPROVED","body":"lgtm","html_url":"https://github.com/owner/repo/pull/1#pullrequestreview-9","user":{"login":"octocat"},"commit_id":"abc","author_association":"MEMBER"}]`) + case path == "/repos/owner/repo/pulls/3/reviews": + // A successful null list payload is normalized to an empty list. + write(http.StatusOK, `null`) + case path == "/repos/owner/repo/issues/1/comments": + write(http.StatusOK, `[{"id":42,"body":"hello","html_url":"https://github.com/owner/repo/pull/1#issuecomment-42"}]`) + case path == "/repos/owner/repo/pulls/1/merge": + write(http.StatusOK, `{"sha":"abc","merged":true,"message":"Pull Request successfully merged"}`) + case path == "/repos/owner/repo/pulls/2/merge": + write(http.StatusOK, `null`) + case path == "/repos/owner/repo/pulls/1/update-branch": + write(http.StatusAccepted, `{"message":"Updating pull request branch.","url":"https://github.com/owner/repo/pull/1"}`) + case path == "/repos/owner/repo/pulls/2/update-branch": + write(http.StatusUnprocessableEntity, `{"message":"merge conflict between base and head"}`) + case path == "/repos/owner/repo/pulls/comments/42/reactions": + write(http.StatusCreated, `{"id":77,"content":"heart"}`) + case path == "/repos/owner/repo/pulls/1/comments": + write(http.StatusCreated, `{"id":43,"body":"reply","html_url":"https://github.com/owner/repo/pull/1#discussion_r43"}`) + default: + t.Errorf("unexpected REST request: %s %s", r.Method, r.URL.Path) + write(http.StatusInternalServerError, `{"message":"unexpected"}`) + } + })}} + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Query string `json:"query"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + var body string + switch q := req.Query; { + case strings.Contains(q, "addPullRequestReviewThread("): + body = `{"data":{"addPullRequestReviewThread":{"thread":{"id":"T_2"}}}}` + case strings.Contains(q, "addPullRequestReview("): + body = `{"data":{"addPullRequestReview":{"pullRequestReview":{"id":"R_1"}}}}` + case strings.Contains(q, "submitPullRequestReview("): + body = `{"data":{"submitPullRequestReview":{"pullRequestReview":{"id":"R_1"}}}}` + case strings.Contains(q, "deletePullRequestReview("): + body = `{"data":{"deletePullRequestReview":{"pullRequestReview":{"id":"R_1"}}}}` + case strings.Contains(q, "unresolveReviewThread("): + body = `{"data":{"unresolveReviewThread":{"thread":{"id":"T_1","isResolved":false}}}}` + case strings.Contains(q, "resolveReviewThread("): + body = `{"data":{"resolveReviewThread":{"thread":{"id":"T_1","isResolved":true}}}}` + case strings.Contains(q, "reviewThreads("): + body = typedPRReviewThreads + case strings.Contains(q, "reviews(first: 100"): + body = `{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[{"id":"R_1","author":{"userId":"U_1"}}],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}}` + case strings.Contains(q, "viewer{"): + body = `{"data":{"viewer":{"id":"U_1"}}}` + case strings.Contains(q, "pullRequest(number: $prNum){id}"): + body = `{"data":{"repository":{"pullRequest":{"id":"PR_1"}}}}` + default: + t.Errorf("unexpected GraphQL query: %s", req.Query) + w.WriteHeader(http.StatusInternalServerError) + return + } + _, _ = w.Write([]byte(body)) + })}} + return BaseDeps{ + Client: mustNewGHClient(t, rest), GQLClient: githubv4.NewClient(gql), + RepoAccessCache: stubRepoAccessCache(nil, time.Minute), + } +} + +type typedPullRequestCase struct { + tool string + args map[string]any + text string +} + +func runTypedPullRequestCases(t *testing.T, session *mcp.ClientSession, schemas map[string]*jsonschema.Resolved, cases []typedPullRequestCase) { + t.Helper() + for _, tc := range cases { + args := map[string]any{"owner": "owner", "repo": "repo"} + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, "%s %v: %s", tc.tool, tc.args, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + text := getTextResult(t, result).Text + schema := schemas[tc.tool] + if schema == nil { + assert.Equal(t, tc.text, text, "%s %v", tc.tool, tc.args) + assert.Nil(t, result.StructuredContent, "%s %v", tc.tool, tc.args) + continue + } + if tc.text == "null" { + assert.Equal(t, "null", text) + // Explicit wire presence is checked by the session middleware. + assert.Nil(t, result.StructuredContent, "%s %v", tc.tool, tc.args) + require.NoError(t, schema.Validate(nil)) + continue + } + require.NotNil(t, result.StructuredContent, "%s %v", tc.tool, tc.args) + var value any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &value)) + require.NoError(t, schema.Validate(value), "%s %v", tc.tool, tc.args) + // The foundation wrapper serializes the compact DTO once for both + // modern JSON text and structuredContent, including diff and message DTOs. + assert.JSONEq(t, mustMarshalJSON(t, value), text, "%s %v", tc.tool, tc.args) + switch { + case tc.args["method"] == "get_diff": + assert.JSONEq(t, mustMarshalJSON(t, PullRequestDiffOutput{Diff: tc.text}), mustMarshalJSON(t, value)) + case tc.tool == "pull_request_review_write" || tc.tool == "add_comment_to_pending_review": + assert.JSONEq(t, mustMarshalJSON(t, RepositoryMessageOutput{Message: tc.text}), mustMarshalJSON(t, value)) + case tc.tool == "update_pull_request_branch": + assert.JSONEq(t, `{"message":"Pull request branch update is in progress"}`, mustMarshalJSON(t, value)) + case tc.tool == "create_pull_request" || tc.tool == "update_pull_request": + assert.JSONEq(t, `{"id":"0","html_url":"https://github.com/owner/repo/pull/1"}`, mustMarshalJSON(t, value)) + case tc.tool == "add_reply_to_pull_request_comment": + switch { + case tc.args["body"] != nil && tc.args["reaction"] != nil: + assert.JSONEq(t, `{"comment":{"id":"43","html_url":"https://github.com/owner/repo/pull/1#discussion_r43"},"reaction":{"id":"77"}}`, mustMarshalJSON(t, value)) + case tc.args["body"] != nil: + assert.JSONEq(t, `{"id":"43","html_url":"https://github.com/owner/repo/pull/1#discussion_r43"}`, mustMarshalJSON(t, value)) + default: + assert.JSONEq(t, `{"id":"77"}`, mustMarshalJSON(t, value)) + } + default: + assert.JSONEq(t, tc.text, mustMarshalJSON(t, value)) + } + } +} + +func TestTypedConsolidatedPullRequestOutputs(t *testing.T) { + const minimalPR = `{"number":1,"title":"Subject","body":"body","state":"open","draft":false,"merged":false,"mergeable_state":"clean","html_url":"https://github.com/owner/repo/pull/1","user":{"login":"octocat"},"head":{"ref":"feature","sha":"abc"},"base":{"ref":"main","sha":"def"},"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}` + const prRef = `{"id":"0","url":"https://github.com/owner/repo/pull/1"}` + cases := []typedPullRequestCase{ + {"pull_request_read", map[string]any{"method": "get", "pullNumber": "1"}, minimalPR}, + {"pull_request_read", map[string]any{"method": "get", "pullNumber": 3}, `null`}, + {"pull_request_read", map[string]any{"method": "get_diff", "pullNumber": 1.0}, typedPRDiff}, + {"pull_request_read", map[string]any{"method": "get_status", "pullNumber": 1}, `{"state":"success","sha":"abc","total_count":1,"statuses":[{"state":"success","context":"ci","target_url":"https://ci"}]}`}, + {"pull_request_read", map[string]any{"method": "get_files", "pullNumber": 1, "page": "1", "perPage": 5}, `[{"filename":"file","status":"modified","additions":1,"changes":1,"patch":"+added"}]`}, + {"pull_request_read", map[string]any{"method": "get_files", "pullNumber": 2}, `[]`}, + {"pull_request_read", map[string]any{"method": "get_commits", "pullNumber": 1}, `[{"sha":"abc","html_url":"https://github.com/owner/repo/commit/abc","message":"msg","author":{"name":"Octo","email":"octo@example.com","date":"2026-01-01T00:00:00Z"}}]`}, + {"pull_request_read", map[string]any{"method": "get_commits", "pullNumber": 2}, `[]`}, + {"pull_request_read", map[string]any{"method": "get_review_comments", "pullNumber": 1, "perPage": 10, "after": "cursor"}, `{"review_threads":[{"id":"T_1","is_resolved":false,"is_outdated":false,"is_collapsed":false,"comments":[{"body":"nit","path":"file","line":3,"original_line":3,"author":"octocat","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","html_url":"https://github.com/owner/repo/pull/1#discussion_r1"}],"total_count":1}],"totalCount":1,"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"startCursor":"s","endCursor":"e"}}`}, + {"pull_request_read", map[string]any{"method": "get_reviews", "pullNumber": 1}, `[{"id":9,"state":"APPROVED","body":"lgtm","html_url":"https://github.com/owner/repo/pull/1#pullrequestreview-9","user":{"login":"octocat"},"commit_id":"abc","author_association":"MEMBER"}]`}, + {"pull_request_read", map[string]any{"method": "get_reviews", "pullNumber": 2}, `[]`}, + {"pull_request_read", map[string]any{"method": "get_reviews", "pullNumber": 3}, `[]`}, + {"pull_request_read", map[string]any{"method": "get_comments", "pullNumber": 1}, `[{"id":42,"body":"hello","html_url":"https://github.com/owner/repo/pull/1#issuecomment-42"}]`}, + {"pull_request_read", map[string]any{"method": "get_comments", "pullNumber": 2}, `[]`}, + {"pull_request_read", map[string]any{"method": "get_check_runs", "pullNumber": 1}, `{"total_count":1,"check_runs":[{"id":5,"name":"build","status":"completed","conclusion":"success"}]}`}, + {"pull_request_read", map[string]any{"method": "get_check_runs", "pullNumber": 1, "page": 2}, `{"total_count":0,"check_runs":[]}`}, + {"create_pull_request", map[string]any{"title": "Subject", "head": "feature", "base": "main", "draft": false}, prRef}, + {"update_pull_request", map[string]any{"pullNumber": "1", "title": "Subject", "maintainer_can_modify": false}, prRef}, + {"merge_pull_request", map[string]any{"pullNumber": 1, "merge_method": "squash", "expectedHeadSha": "abc"}, `{"sha":"abc","merged":true,"message":"Pull Request successfully merged"}`}, + {"merge_pull_request", map[string]any{"pullNumber": 2}, `null`}, + {"merge_pull_request", map[string]any{"pullNumber": 1.0, "merge_method": "squash", "expectedHeadSha": "abc"}, `{"sha":"abc","merged":true,"message":"Pull Request successfully merged"}`}, + {"update_pull_request_branch", map[string]any{"pullNumber": "1", "expectedHeadSha": "abc"}, "Pull request branch update is in progress"}, + {"pull_request_review_write", map[string]any{"method": "create", "pullNumber": "1"}, "pending pull request created"}, + {"pull_request_review_write", map[string]any{"method": "create", "pullNumber": 1, "event": "APPROVE", "body": "lgtm", "commitID": "abc"}, "pull request review submitted successfully"}, + {"pull_request_review_write", map[string]any{"method": "submit_pending", "pullNumber": 1, "event": "COMMENT", "body": "done"}, "pending pull request review successfully submitted"}, + {"pull_request_review_write", map[string]any{"method": "delete_pending", "pullNumber": 1}, "pending pull request review successfully deleted"}, + {"pull_request_review_write", map[string]any{"method": "resolve_thread", "threadId": "T_1"}, "review thread resolved successfully"}, + {"pull_request_review_write", map[string]any{"method": "unresolve_thread", "threadId": "T_1"}, "review thread unresolved successfully"}, + {"add_comment_to_pending_review", map[string]any{"pullNumber": "1", "path": "file", "body": "nit", "subjectType": "LINE", "line": "3", "side": "RIGHT", "startLine": 2, "startSide": 7}, "pull request review comment successfully added to pending review"}, + {"add_reply_to_pull_request_comment", map[string]any{"pullNumber": "1", "commentId": "42", "body": "reply"}, `{"id":"43","url":"https://github.com/owner/repo/pull/1#discussion_r43"}`}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": 42, "pullNumber": "x", "reaction": "heart"}, `{"id":"77","url":"https://api.github.com/repos/owner/repo/pulls/comments/42/reactions/77"}`}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": 42, "reaction": "heart"}, `{"id":"77","url":"https://api.github.com/repos/owner/repo/pulls/comments/42/reactions/77"}`}, + {"add_reply_to_pull_request_comment", map[string]any{"pullNumber": 1, "commentId": 42.0, "body": "reply", "reaction": "heart"}, `{"comment":{"id":"43","url":"https://github.com/owner/repo/pull/1#discussion_r43"},"reaction":{"id":"77","url":"https://api.github.com/repos/owner/repo/pulls/comments/42/reactions/77"}}`}, + } + for _, protocol := range typedPullRequestProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + session, schemas := consolidatedPullRequestSession(t, consolidatedPullRequestDeps(t), protocol, false) + runTypedPullRequestCases(t, session, schemas, cases) + }) + } +} + +func TestTypedPullRequestFilesLinkPagination(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + var requestedPages []string + rest := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/repos/owner/repo/pulls/1/files", strings.TrimPrefix(r.URL.Path, "/api/v3")) + require.Equal(t, "1", r.URL.Query().Get("per_page")) + page := r.URL.Query().Get("page") + requestedPages = append(requestedPages, page) + switch page { + case "1": + w.Header().Set("Link", `; rel="next"`) + _, _ = w.Write([]byte(`[{"filename":"first","status":"modified","additions":1,"changes":1}]`)) + case "2": + _, _ = w.Write([]byte(`[{"filename":"second","status":"added","additions":1,"changes":1}]`)) + default: + t.Errorf("unexpected page query: %q", page) + w.WriteHeader(http.StatusInternalServerError) + } + })}} + deps := BaseDeps{ + Client: mustNewGHClient(t, rest), GQLClient: githubv4.NewClient(rest), + RepoAccessCache: stubRepoAccessCache(nil, time.Minute), + } + session, schemas := consolidatedPullRequestSession(t, deps, protocol, false) + runTypedPullRequestCases(t, session, schemas, []typedPullRequestCase{ + {"pull_request_read", map[string]any{"method": "get_files", "pullNumber": 1, "page": 1, "perPage": 1}, `[{"filename":"first","status":"modified","additions":1,"changes":1}]`}, + {"pull_request_read", map[string]any{"method": "get_files", "pullNumber": 1, "page": 2, "perPage": 1}, `[{"filename":"second","status":"added","additions":1,"changes":1}]`}, + }) + assert.Equal(t, []string{"1", "2"}, requestedPages, "explicit page calls must follow the API Link header's next-page target") + }) + } +} + +func TestTypedPullRequestReviewWriteResolutionReasonVariant(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + session, schemas := consolidatedPullRequestSession(t, consolidatedPullRequestDeps(t), protocol, false, FeatureFlagThreadResolutionReason) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + for _, tool := range list.Tools { + if tool.Name == "pull_request_review_write" { + assert.Contains(t, mustMarshalJSON(t, tool.InputSchema), "resolutionReason") + } + } + runTypedPullRequestCases(t, session, schemas, []typedPullRequestCase{ + {"pull_request_review_write", map[string]any{"Method": "resolve_thread", "ThreadID": "T_1", "resolutionReason": "OUTDATED"}, "review thread resolved successfully"}, + {"pull_request_review_write", map[string]any{"method": "create", "pullNumber": 1.5}, "pending pull request created"}, + }) + }) + } +} + +func TestTypedPullRequestWriteAwaitingForm(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + session, schemas := consolidatedPullRequestSession(t, consolidatedPullRequestDeps(t), protocol, true) + for tool, args := range map[string]map[string]any{ + "create_pull_request": {"owner": "owner", "repo": "repo", "title": "Subject", "head": "feature", "base": "main"}, + "update_pull_request": {"owner": "owner", "repo": "repo", "pullNumber": 1, "title": "Subject"}, + } { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool, Arguments: args}) + require.NoError(t, err) + require.True(t, result.IsError, tool) + assert.Contains(t, getErrorResult(t, result).Text, "interactive form") + if protocol == inventory.ProtocolVersionMultiRoundTrip { + require.NotNil(t, result.StructuredContent) + assert.JSONEq(t, `{"status":"awaiting_user_submission","reason":"An interactive form is being shown to the user. The operation has not been performed."}`, mustMarshalJSON(t, result.StructuredContent)) + var value any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &value)) + require.NoError(t, schemas[tool].Validate(value)) + } else { + assert.Nil(t, result.StructuredContent) + } + args["_ui_submitted"] = true + runTypedPullRequestCases(t, session, schemas, []typedPullRequestCase{{tool, args, `{"id":"0","url":"https://github.com/owner/repo/pull/1"}`}}) + } + }) + } +} + +func TestTypedConsolidatedPullRequestErrors(t *testing.T) { + cases := []typedPullRequestCase{ + {"pull_request_read", map[string]any{"pullNumber": 1}, "missing required parameter: method"}, + {"pull_request_read", map[string]any{"method": "get", "pullNumber": 0}, "missing required parameter: pullNumber"}, + {"pull_request_read", map[string]any{"method": "get", "pullNumber": "1.5"}, "parameter pullNumber is not a valid number: non-integer numeric value: 1.5"}, + {"pull_request_read", map[string]any{"method": "get_files", "pullNumber": 1, "page": "x"}, "parameter page is not a valid number"}, + {"pull_request_read", map[string]any{"method": "unknown", "pullNumber": 1}, "unknown method: unknown"}, + {"pull_request_read", map[string]any{"method": "get", "pullNumber": 1.5}, "parameter pullNumber is not a valid number: non-integer numeric value: 1.5"}, + {"create_pull_request", map[string]any{"title": "Subject", "head": "feature", "base": "main", "draft": "yes"}, "parameter draft is not of type bool, is string"}, + {"update_pull_request", map[string]any{"pullNumber": 0, "title": "Subject"}, "missing required parameter: pullNumber"}, + {"update_pull_request", map[string]any{"pullNumber": 1, "draft": "yes"}, "parameter draft is not of type bool, is string"}, + {"pull_request_review_write", map[string]any{"method": "nope", "pullNumber": 1}, "unknown method: nope"}, + {"add_comment_to_pending_review", map[string]any{"pullNumber": 1, "body": "nit", "subjectType": "LINE"}, "missing required parameter: path"}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": 0, "body": "reply"}, "missing required parameter: commentId"}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": -1, "body": "reply"}, "commentId must be greater than 0"}, + {"create_pull_request", map[string]any{"head": "feature", "base": "main"}, "missing required parameter: title"}, + {"update_pull_request", map[string]any{"pullNumber": 1}, "No update parameters provided"}, + {"merge_pull_request", map[string]any{"pullNumber": "abc"}, "parameter pullNumber is not a valid number: invalid numeric value: abc"}, + {"update_pull_request_branch", map[string]any{"pullNumber": 2}, "merge conflict between base and head"}, + {"pull_request_review_write", map[string]any{"method": "resolve_thread"}, "threadId is required for resolve_thread and unresolve_thread methods"}, + {"pull_request_review_write", map[string]any{"method": "create", "pullNumber": "x"}, "'PullNumber' cannot parse value as 'int32'"}, + {"add_comment_to_pending_review", map[string]any{"pullNumber": 1, "path": "file", "body": "nit", "subjectType": "LINE", "line": 1.5}, "parameter line is not a valid number"}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": 42}, "at least one of body or reaction is required"}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": 42, "body": "reply"}, "missing required parameter: pullNumber"}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": "1e30", "body": "reply"}, "parameter commentId is not a valid number: numeric value 1e+30 is too large to fit in int64"}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": 42, "pullNumber": "x", "body": "reply"}, "parameter pullNumber is not a valid number: invalid numeric value: x"}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": 42, "pullNumber": "x", "body": "reply", "reaction": 1}, "parameter reaction is not of type string, is float64"}, + } + for _, protocol := range typedPullRequestProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + session, _ := consolidatedPullRequestSession(t, consolidatedPullRequestDeps(t), protocol, false) + for _, tc := range cases { + args := map[string]any{"owner": "owner", "repo": "repo"} + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool, Arguments: args}) + require.NoError(t, err, "%s %v", tc.tool, tc.args) + require.True(t, result.IsError, "%s %v: %s", tc.tool, tc.args, mustMarshalJSON(t, result)) + assert.Contains(t, getErrorResult(t, result).Text, tc.text, "%s %v", tc.tool, tc.args) + assert.Nil(t, result.StructuredContent, "%s %v", tc.tool, tc.args) + } + }) + } +} + +func TestTypedConsolidatedPullRequestAPIErrors(t *testing.T) { + forbidden := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + })}} + calls := []typedPullRequestCase{ + {"create_pull_request", map[string]any{"title": "Subject", "head": "feature", "base": "main"}, ""}, + {"update_pull_request", map[string]any{"pullNumber": 1, "title": "Subject"}, ""}, + {"merge_pull_request", map[string]any{"pullNumber": 1}, ""}, + {"update_pull_request_branch", map[string]any{"pullNumber": 1}, ""}, + {"pull_request_review_write", map[string]any{"method": "create", "pullNumber": 1}, ""}, + {"pull_request_review_write", map[string]any{"method": "submit_pending", "pullNumber": 1}, ""}, + {"pull_request_review_write", map[string]any{"method": "delete_pending", "pullNumber": 1}, ""}, + {"pull_request_review_write", map[string]any{"method": "resolve_thread", "threadId": "T_1"}, ""}, + {"pull_request_review_write", map[string]any{"method": "unresolve_thread", "threadId": "T_1"}, ""}, + {"add_comment_to_pending_review", map[string]any{"pullNumber": 1, "path": "file", "body": "nit", "subjectType": "FILE"}, ""}, + {"add_reply_to_pull_request_comment", map[string]any{"pullNumber": 1, "commentId": 42, "body": "reply"}, ""}, + } + for _, method := range []string{"get", "get_diff", "get_status", "get_files", "get_commits", "get_review_comments", "get_reviews", "get_comments", "get_check_runs"} { + calls = append(calls, typedPullRequestCase{"pull_request_read", map[string]any{"method": method, "pullNumber": 1}, ""}) + } + for _, protocol := range typedPullRequestProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, forbidden), GQLClient: githubv4.NewClient(forbidden), + RepoAccessCache: stubRepoAccessCache(nil, time.Minute), + } + session, _ := consolidatedPullRequestSession(t, deps, protocol, false) + for _, tc := range calls { + args := map[string]any{"owner": "owner", "repo": "repo"} + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool, Arguments: args}) + require.NoError(t, err, "%s %v", tc.tool, tc.args) + require.True(t, result.IsError, "%s %v: %s", tc.tool, tc.args, mustMarshalJSON(t, result)) + assert.Nil(t, result.StructuredContent, "%s %v", tc.tool, tc.args) + } + }) + } +} + +func TestTypedPullRequestContentParity(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + for _, permission := range []string{"read", "write"} { + t.Run(protocol+"/"+permission, func(t *testing.T) { + deps := consolidatedPullRequestDeps(t) + deps.Client = mustNewGHClient(t, &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch strings.TrimPrefix(r.URL.Path, "/api/v3") { + case "/repos/owner/repo": + _, _ = w.Write([]byte(`{"private":false}`)) + case "/repos/owner/repo/pulls/1": + _, _ = w.Write([]byte(`{"number":1,"title":"Subject","body":"bo\u200bdy","state":"open","html_url":"https://github.com/owner/repo/pull/1","user":{"login":"octocat"}}`)) + default: + t.Errorf("unexpected request: %s", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + } + })}}) + deps.Flags = stubFeatureFlags(map[string]bool{"lockdown-mode": true}) + deps.RepoAccessCache = stubRepoAccessCache(mockRESTPermissionServer(t, permission, nil), time.Minute) + deps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + session, schemas := consolidatedPullRequestSession(t, deps, protocol, false) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "pull_request_read", + Arguments: map[string]any{"method": "get", "owner": "owner", "repo": "repo", "pullNumber": 1}, + }) + require.NoError(t, err) + if permission == "read" { + require.True(t, result.IsError) + assert.Equal(t, "access to pull request is restricted by lockdown mode", getErrorResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + return + } + require.False(t, result.IsError) + text := getTextResult(t, result).Text + require.NotNil(t, result.Meta["ifc"]) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + assert.JSONEq(t, mustMarshalJSON(t, result.StructuredContent), text) + require.NoError(t, schemas["pull_request_read"].Validate(result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + assert.Equal(t, `{"number":1,"title":"Subject","body":"body","state":"open","draft":false,"merged":false,"html_url":"https://github.com/owner/repo/pull/1","user":{"login":"octocat"}}`, text) + } + }) + } + } +} + +func TestConsolidatedPullRequestOutputSchemas(t *testing.T) { + for _, tc := range []struct { + schema *jsonschema.Schema + valid []string + invalid []string + }{ + { + pullRequestReadOutputSchema(), + []string{`null`, `[]`, `{"diff":""}`, `{"number":1,"title":"","state":"","draft":false,"merged":false,"html_url":""}`, `{"state":"","sha":"","total_count":0,"statuses":[]}`, `{"total_count":0,"check_runs":[]}`, `{"review_threads":[],"totalCount":0,"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`, `[{"filename":"f"}]`, `[{"id":1,"html_url":""}]`}, + []string{`{}`, `"diff"`, `{"diff":1}`, `[{"unknown":1}]`, `{"number":1}`, `{"number":1,"title":"","state":"unknown","draft":false,"merged":false,"html_url":""}`}, + }, + { + pullRequestWriteOutputSchema(), + []string{`{"id":"","html_url":""}`, `{"status":"awaiting_user_submission","reason":"wait"}`}, + []string{`{}`, `null`, `{"status":"created","reason":"wait"}`}, + }, + { + pullRequestCommentReplyOutputSchema(), + []string{`{"id":"1","html_url":"u"}`, `{"comment":{"id":"1","html_url":"u"},"reaction":{"id":"2"}}`}, + []string{`{}`, `{"comment":{"id":"1","html_url":"u"}}`, `null`, `{"id":"1","url":"https://api.github.com/derivable"}`}, + }, + { + pullRequestMergeOutputSchema(), + []string{`null`, `{}`, `{"sha":"abc","merged":true,"message":"ok"}`}, + []string{`[]`, `{"merged":"yes"}`}, + }, + { + pullRequestBranchUpdateOutputSchema(), + []string{`null`, `{"message":"m"}`}, + []string{`[]`, `{"message":1}`, `{"message":"m","url":"https://api.github.com/derivable"}`}, + }, + } { + resolved, err := tc.schema.Resolve(nil) + require.NoError(t, err) + for _, raw := range tc.valid { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + require.NoError(t, resolved.Validate(value), raw) + } + + for _, raw := range tc.invalid { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + require.Error(t, resolved.Validate(value), raw) + } + } + // Zero outputs are validated by the SDK even for errors, so they must conform. + for _, tc := range []struct { + schema *jsonschema.Schema + value any + }{ + {pullRequestReadOutputSchema(), PullRequestReadOutput{}}, + {pullRequestWriteOutputSchema(), PullRequestWriteOutput{}}, + {pullRequestCommentReplyOutputSchema(), PullRequestCommentReplyOutput{}}, + {pullRequestMergeOutputSchema(), PullRequestMergeOutput{}}, + {pullRequestBranchUpdateOutputSchema(), PullRequestBranchUpdateOutput{}}, + } { + resolved, err := tc.schema.Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tc.value)), &value)) + require.NoError(t, resolved.Validate(value), mustMarshalJSON(t, tc.value)) + } +} + +func TestPullRequestOutputDescriptionsAreShapeAware(t *testing.T) { + commitSchema := pullRequestOutputSchema[MinimalPullRequestCommit]() + require.Contains(t, commitSchema.Properties, "message") + assert.Empty(t, commitSchema.Properties["message"].Description) + + branchUpdateSchema := pullRequestBranchUpdateOutputSchema() + require.Contains(t, branchUpdateSchema.Properties, "message") + assert.Equal(t, "Operation result message.", branchUpdateSchema.Properties["message"].Description) +} + +func TestConsolidatedPullRequestOutputTypesAreCurated(t *testing.T) { + visited := make(map[reflect.Type]bool) + var check func(reflect.Type) + check = func(typ reflect.Type) { + if visited[typ] { + return + } + visited[typ] = true + require.NotContains(t, typ.PkgPath(), "github.com/google/go-github", typ.String()) + switch typ.Kind() { + case reflect.Pointer, reflect.Slice, reflect.Array: + check(typ.Elem()) + case reflect.Struct: + for field := range typ.Fields() { + check(field.Type) + } + case reflect.Map, reflect.Interface: + t.Errorf("non-concrete output field: %s", typ) + } + } + for _, typ := range []reflect.Type{ + reflect.TypeFor[PullRequestReadOutput](), reflect.TypeFor[PullRequestWriteOutput](), + reflect.TypeFor[PullRequestMergeOutput](), reflect.TypeFor[PullRequestBranchUpdateOutput](), + reflect.TypeFor[PullRequestCommentReplyOutput](), + } { + check(typ) + } +} diff --git a/pkg/github/typed_contracts_test.go b/pkg/github/typed_contracts_test.go new file mode 100644 index 0000000000..bcb89e0f1e --- /dev/null +++ b/pkg/github/typed_contracts_test.go @@ -0,0 +1,117 @@ +package github + +import ( + "encoding/json" + "testing" + + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActionsOutputMethodContracts(t *testing.T) { + for _, tc := range []struct { + name string + schema *jsonschema.Schema + method string + field string + other string + value string + optional bool + }{ + {"list/workflows", actionsListOutputSchema(), "list_workflows", "workflows", "artifacts", `{}`, true}, + {"list/runs", actionsListOutputSchema(), "list_workflow_runs", "workflow_runs", "workflows", `{"total_count":0,"workflow_runs":[]}`, false}, + {"list/jobs", actionsListOutputSchema(), "list_workflow_jobs", "workflow_jobs", "workflows", `{"total_count":0,"jobs":[]}`, false}, + {"list/artifacts", actionsListOutputSchema(), "list_workflow_run_artifacts", "artifacts", "workflows", `{}`, true}, + {"get/workflow", actionsGetOutputSchema(), "get_workflow", "workflow", "workflow_run", `{}`, true}, + {"get/run", actionsGetOutputSchema(), "get_workflow_run", "workflow_run", "workflow", `{"id":7,"name":"","status":"","workflow_id":0,"head_branch":"","run_number":0,"run_attempt":0}`, false}, + {"get/job", actionsGetOutputSchema(), "get_workflow_job", "workflow_job", "workflow", `{"id":8,"run_id":7,"name":"","status":""}`, true}, + {"get/usage", actionsGetOutputSchema(), "get_workflow_run_usage", "usage", "workflow", `{}`, true}, + {"get/artifact", actionsGetOutputSchema(), "download_workflow_run_artifact", "artifact", "workflow", `{"artifact_id":9,"download_url":"","message":"","note":""}`, false}, + {"get/logs", actionsGetOutputSchema(), "get_workflow_run_logs_url", "logs", "workflow", `{"logs_url":"","message":"","note":"","optimization_tip":"","warning":""}`, false}, + {"trigger/dispatch", actionsRunTriggerOutputSchema(), "run_workflow", "dispatch", "cancel", `{"inputs":null,"message":"","ref":"main","status":"","status_code":204,"workflow_id":"ci.yml","workflow_type":"workflow_file"}`, false}, + {"trigger/rerun", actionsRunTriggerOutputSchema(), "rerun_workflow_run", "rerun", "cancel", `{"message":"","run_id":7,"status":"","status_code":201}`, false}, + {"trigger/rerun_failed", actionsRunTriggerOutputSchema(), "rerun_failed_jobs", "rerun_failed", "cancel", `{"message":"","run_id":7,"status":"","status_code":201}`, false}, + {"trigger/cancel", actionsRunTriggerOutputSchema(), "cancel_workflow_run", "cancel", "rerun", `{"message":"","run_id":7,"status":"","status_code":202}`, false}, + {"trigger/delete_logs", actionsRunTriggerOutputSchema(), "delete_workflow_run_logs", "delete_logs", "rerun", `{"message":"","run_id":7,"status":"","status_code":204}`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + schema, err := tc.schema.Resolve(nil) + require.NoError(t, err) + valid := []string{`{"method":"` + tc.method + `","` + tc.field + `":` + tc.value + `}`} + if tc.optional { + valid = append(valid, `{"method":"`+tc.method+`"}`, `{"method":"`+tc.method+`","`+tc.field+`":null}`) + } + for _, raw := range valid { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + assert.NoError(t, schema.Validate(value), raw) + } + invalid := []string{ + `{}`, + `{"` + tc.field + `":` + tc.value + `}`, + `{"method":"` + tc.method + `","` + tc.other + `":` + tc.value + `}`, + `{"method":"` + tc.method + `","` + tc.field + `":` + tc.value + `,"` + tc.other + `":` + tc.value + `}`, + `{"method":"` + tc.method + `","` + tc.other + `":null}`, + `{"method":"` + tc.method + `","` + tc.field + `":` + tc.value + `,"` + tc.other + `":null}`, + } + if !tc.optional { + invalid = append(invalid, `{"method":"`+tc.method+`"}`) + } + for _, raw := range invalid { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + assert.Error(t, schema.Validate(value), raw) + } + }) + } +} + +func TestUIGetOutputRejectsMismatchedPayloads(t *testing.T) { + schema, err := uiGetOutputSchema().Resolve(nil) + require.NoError(t, err) + for _, method := range []string{"labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers"} { + t.Run(method, func(t *testing.T) { + payload := "labels" + if method == payload { + payload = "assignees" + } + for _, value := range []map[string]any{ + {"method": method}, + {"method": method, payload: map[string]any{payload: []any{}, "totalCount": 0, "has_more": false}}, + } { + assert.Error(t, schema.Validate(value), "%v", value) + } + }) + } + assert.Error(t, schema.Validate(map[string]any{ + "method": "labels", + "labels": map[string]any{"labels": []any{}, "totalCount": 0, "has_more": false}, + "issue_types": []any{}, + })) +} + +func TestProjectOutputUsersExcludeDetails(t *testing.T) { + user := &github.User{Login: new("octocat"), Email: new("private@example.com")} + project := convertToMinimalProject(&github.ProjectV2{Owner: user, Creator: user, DeletedBy: user}) + require.Nil(t, project.Owner.Details) + require.Nil(t, project.Creator.Details) + require.Nil(t, project.DeletedBy.Details) + var status statusUpdateNode + status.Creator.Login = "octocat" + require.Nil(t, convertToMinimalStatusUpdate(status).Creator.Details) + for name, schema := range map[string]*jsonschema.Schema{ + "list": projectsListOutputSchema(), "get": projectsGetOutputSchema(), "write": projectsWriteOutputSchema(), + } { + t.Run(name, func(t *testing.T) { + assert.NotContains(t, mustMarshalJSON(t, schema), `"details"`) + }) + } + schema, err := projectOutputSchema[MinimalProject]().Resolve(nil) + require.NoError(t, err) + for _, field := range []string{"owner", "creator", "deleted_by"} { + assert.NoError(t, schema.Validate(map[string]any{field: map[string]any{"login": "octocat"}})) + assert.Error(t, schema.Validate(map[string]any{field: map[string]any{"login": "octocat", "details": map[string]any{}}})) + } +} diff --git a/pkg/github/typed_copilot_ui_outputs.go b/pkg/github/typed_copilot_ui_outputs.go new file mode 100644 index 0000000000..cefedb6893 --- /dev/null +++ b/pkg/github/typed_copilot_ui_outputs.go @@ -0,0 +1,401 @@ +package github + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/go-viper/mapstructure/v2" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type CopilotPullRequestOutput struct { + Number int `json:"number"` + State string `json:"state"` + Title string `json:"title"` + URL string `json:"url"` +} + +type AssignCopilotToIssueOutput struct { + IssueNumber int `json:"issue_number"` + IssueURL string `json:"issue_url"` + Message string `json:"message"` + Note string `json:"note,omitempty"` + Owner string `json:"owner"` + PullRequest *CopilotPullRequestOutput `json:"pull_request,omitempty"` + Repo string `json:"repo"` +} + +type AssignCopilotToIssueWithIntentOutput struct { + IsSuggestion bool `json:"is_suggestion"` + IssueNumber int `json:"issue_number"` + IssueURL string `json:"issue_url"` + Message string `json:"message"` + Note string `json:"note,omitempty"` + Owner string `json:"owner"` + PullRequest *CopilotPullRequestOutput `json:"pull_request,omitempty"` + Repo string `json:"repo"` +} + +func assignCopilotToIssueOutputSchema() *jsonschema.Schema { + schema := forbidOmittedNulls(repositoryOutputSchema[AssignCopilotToIssueOutput]()) + schema.Properties["issue_number"].Description = "The assigned issue's number." + schema.Properties["issue_url"].Description = "Canonical browser URL of the assigned issue." + schema.Properties["message"].Description = "Assignment outcome or tool error message." + schema.Properties["note"].Description = "Additional status detail when Copilot has not created a pull request yet." + schema.Properties["pull_request"].Properties["state"].Enum = []any{"OPEN", "CLOSED", "MERGED"} + schema.Properties["pull_request"].Properties["url"].Description = "Canonical browser URL of the linked pull request." + return schema +} + +func assignCopilotToIssueWithIntentOutputSchema() *jsonschema.Schema { + schema := forbidOmittedNulls(repositoryOutputSchema[AssignCopilotToIssueWithIntentOutput]()) + schema.Properties["issue_number"].Description = "The assigned issue's number." + schema.Properties["issue_url"].Description = "Canonical browser URL of the assigned issue." + schema.Properties["message"].Description = "Assignment outcome or tool error message." + schema.Properties["note"].Description = "Additional status detail when Copilot has not created a pull request yet." + schema.Properties["pull_request"].Properties["state"].Enum = []any{"OPEN", "CLOSED", "MERGED"} + schema.Properties["pull_request"].Properties["url"].Description = "Canonical browser URL of the linked pull request." + return schema +} + +type CopilotReviewOutput struct { + Status string `json:"status"` +} + +func copilotReviewOutputSchema() *jsonschema.Schema { + schema := forbidOmittedNulls(repositoryOutputSchema[CopilotReviewOutput]()) + schema.Properties["status"].Description = "Outcome of the Copilot review request; requested on success." + return schema +} + +func normalizeAssignCopilotToIssueArguments(raw json.RawMessage) (json.RawMessage, error) { + args, err := rawArgumentMap(raw) + if err != nil || args == nil { + return raw, err + } + var input 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, &input); err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + return json.Marshal(map[string]any{ + "owner": input.Owner, + "repo": input.Repo, + "issue_number": input.IssueNumber, + "base_ref": input.BaseRef, + "custom_instructions": input.CustomInstructions, + }) +} + +func normalizeAssignCopilotToIssueWithIntentArguments(raw json.RawMessage) (json.RawMessage, error) { + args, err := rawArgumentMap(raw) + if err != nil || args == nil { + return raw, err + } + if _, ok := args["is_suggestion"]; !ok { + return nil, &inventory.ToolInputError{Message: "is_suggestion is required"} + } + var input struct { + Owner string `mapstructure:"owner"` + Repo string `mapstructure:"repo"` + IssueNumber int32 `mapstructure:"issue_number"` + BaseRef string `mapstructure:"base_ref"` + CustomInstructions string `mapstructure:"custom_instructions"` + Rationale string `mapstructure:"rationale"` + Confidence string `mapstructure:"confidence"` + IsSuggestion bool `mapstructure:"is_suggestion"` + } + if err := mapstructure.WeakDecode(args, &input); err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + rationale := strings.TrimSpace(input.Rationale) + if rationale == "" || len([]rune(rationale)) > 280 { + rationale = "compatibility" + } + confidence := normalizeConfidence(input.Confidence) + switch confidence { + case "LOW", "MEDIUM", "HIGH": + default: + confidence = "LOW" + } + return json.Marshal(map[string]any{ + "owner": input.Owner, + "repo": input.Repo, + "issue_number": input.IssueNumber, + "base_ref": input.BaseRef, + "custom_instructions": input.CustomInstructions, + "rationale": rationale, + "_compat_rationale": input.Rationale, + "confidence": confidence, + "_compat_confidence": input.Confidence, + "is_suggestion": input.IsSuggestion, + }) +} + +func normalizeRequestCopilotReviewArguments(raw json.RawMessage) (json.RawMessage, error) { + args, err := rawArgumentMap(raw) + if err != nil || args == nil { + return raw, err + } + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + pullNumber, err := RequiredInt(args, "pullNumber") + if err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + return json.Marshal(map[string]any{"owner": owner, "repo": repo, "pullNumber": pullNumber}) +} + +func normalizeUIGetArguments(raw json.RawMessage) (json.RawMessage, error) { + args, err := rawArgumentMap(raw) + if err != nil || args == nil { + return raw, err + } + delete(args, "_compat_method") + method, err := RequiredParam[string](args, "method") + if err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + if _, err := RequiredParam[string](args, "owner"); err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + switch method { + case "labels", "assignees", "milestones", "branches", "issue_fields", "reviewers": + if _, err := RequiredParam[string](args, "repo"); err != nil { + if _, exists := args["repo"]; exists { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + } + case "issue_types": + if _, exists := args["repo"]; exists { + if _, err := RequiredParam[string](args, "repo"); err != nil { + args["repo"] = "" + } + } + default: + return nil, &inventory.ToolInputError{Message: fmt.Sprintf("unknown method: %s", method)} + } + return json.Marshal(args) +} + +func rawArgumentMap(raw json.RawMessage) (map[string]any, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + return args, nil +} + +type UIGetLabelOutput struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` +} + +type UIGetAssigneeOutput struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` +} + +type UIGetMilestoneOutput struct { + Number int `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` + OpenIssues int `json:"open_issues"` + DueOn string `json:"due_on"` +} + +type UIGetBranchesOutput struct { + Branches []MinimalBranch `json:"branches"` + TotalCount int `json:"totalCount"` + HasMore bool `json:"has_more"` +} + +type UIGetIssueFieldOptionOutput struct { + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` +} + +type UIGetIssueFieldOutput struct { + ID string `json:"id"` + Name string `json:"name"` + DataType string `json:"data_type"` + Description string `json:"description"` + Options *[]UIGetIssueFieldOptionOutput `json:"options,omitempty"` +} + +type UIGetReviewersOutput struct { + Users []UIGetAssigneeOutput `json:"users"` + Teams []UIGetReviewerTeam `json:"teams"` + TotalCount int `json:"totalCount"` + HasMore bool `json:"has_more"` +} + +type UIGetReviewerTeam struct { + Slug string `json:"slug"` + Name string `json:"name"` + Org string `json:"org"` +} + +type UIGetLabelsOutput struct { + Labels []UIGetLabelOutput `json:"labels"` + TotalCount int `json:"totalCount"` + HasMore bool `json:"has_more"` +} + +type UIGetAssigneesOutput struct { + Assignees []UIGetAssigneeOutput `json:"assignees"` + TotalCount int `json:"totalCount"` + HasMore bool `json:"has_more"` +} + +type UIGetMilestonesOutput struct { + Milestones []UIGetMilestoneOutput `json:"milestones"` + TotalCount int `json:"totalCount"` + HasMore bool `json:"has_more"` +} + +type UIGetIssueFieldsOutput struct { + Fields []UIGetIssueFieldOutput `json:"fields"` + TotalCount int `json:"totalCount"` +} + +type UIGetOutput struct { + Method string `json:"method"` + Labels *UIGetLabelsOutput `json:"labels,omitempty"` + Assignees *UIGetAssigneesOutput `json:"assignees,omitempty"` + Milestones *UIGetMilestonesOutput `json:"milestones,omitempty"` + IssueTypes *[]*IssueTypeOutput `json:"issue_types,omitempty"` + Branches *UIGetBranchesOutput `json:"branches,omitempty"` + IssueFields *UIGetIssueFieldsOutput `json:"issue_fields,omitempty"` + Reviewers *UIGetReviewersOutput `json:"reviewers,omitempty"` +} + +func decodeUIGetOutput(method string, content []byte) (*UIGetOutput, error) { + output := &UIGetOutput{Method: method} + switch method { + case "labels": + output.Labels = new(UIGetLabelsOutput) + err := json.Unmarshal(content, output.Labels) + return output, err + case "assignees": + output.Assignees = new(UIGetAssigneesOutput) + err := json.Unmarshal(content, output.Assignees) + return output, err + case "milestones": + output.Milestones = new(UIGetMilestonesOutput) + err := json.Unmarshal(content, output.Milestones) + return output, err + case "issue_types": + var issueTypes []*IssueTypeOutput + err := json.Unmarshal(content, &issueTypes) + output.IssueTypes = &issueTypes + return output, err + case "branches": + output.Branches = new(UIGetBranchesOutput) + err := json.Unmarshal(content, output.Branches) + return output, err + case "issue_fields": + output.IssueFields = new(UIGetIssueFieldsOutput) + err := json.Unmarshal(content, output.IssueFields) + return output, err + case "reviewers": + output.Reviewers = new(UIGetReviewersOutput) + err := json.Unmarshal(content, output.Reviewers) + return output, err + default: + return nil, fmt.Errorf("unknown ui_get output method: %s", method) + } +} + +func uiGetOutputSchema() *jsonschema.Schema { + schema := forbidOmittedNulls(repositoryOutputSchema[UIGetOutput]()) + schema.Properties["method"].Enum = []any{"labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers"} + schema.Properties["method"].Description = "The method that produced the corresponding typed output." + schema.Required = []string{"method"} + schema.Properties["issue_types"].Type = "" + schema.Properties["issue_types"].Types = []string{"array", "null"} + schema.Properties["issue_types"].Items = repositoryOutputSchema[[]*IssueTypeOutput]().Items + schema.Properties["labels"].Properties["totalCount"].Description = "Total number of matching labels." + schema.Properties["labels"].Properties["has_more"].Description = "True when results were truncated at the pagination limit." + schema.Properties["assignees"].Properties["totalCount"].Description = "Total number of matching assignees." + schema.Properties["assignees"].Properties["has_more"].Description = "True when results were truncated at the pagination limit." + schema.Properties["milestones"].Properties["totalCount"].Description = "Total number of matching milestones." + schema.Properties["milestones"].Properties["has_more"].Description = "True when results were truncated at the pagination limit." + schema.Properties["reviewers"].Properties["totalCount"].Description = "Total number of matching users and teams." + typedArrays := map[string][]string{ + "labels": {"labels"}, + "assignees": {"assignees"}, + "milestones": {"milestones"}, + "branches": {"branches"}, + "issue_fields": {"fields"}, + "reviewers": {"users", "teams"}, + } + for property, arrays := range typedArrays { + nested := schema.Properties[property] + for _, name := range arrays { + array := nested.Properties[name] + array.Type, array.Types = "array", nil + } + } + schema.Properties["milestones"].Properties["milestones"].Items.Properties["number"].Description = "Milestone number." + schema.Properties["milestones"].Properties["milestones"].Items.Properties["open_issues"].Description = "Number of open issues assigned to the milestone." + schema.Properties["milestones"].Properties["milestones"].Items.Properties["due_on"].Description = "Due date in YYYY-MM-DD form, or empty when no due date is set." + schema.Properties["milestones"].Properties["milestones"].Items.Properties["state"].Enum = []any{"open", "closed"} + schema.Properties["issue_fields"].Properties["fields"].Items.Properties["data_type"].Enum = []any{"text", "number", "date", "single_select"} + schema.Properties["issue_fields"].Properties["totalCount"].Description = "Number of supported issue fields returned." + schema.Properties["branches"].Properties["totalCount"].Description = "Number of branches returned." + schema.Properties["branches"].Properties["has_more"].Description = "True when results were truncated at the pagination limit." + constrainMethodOutput(schema, map[string]string{ + "labels": "labels", "assignees": "assignees", "milestones": "milestones", + "issue_types": "issue_types", "branches": "branches", "issue_fields": "issue_fields", "reviewers": "reviewers", + }) + return schema +} + +func uiGetTypedResult(method string, result *mcp.CallToolResult) (*mcp.CallToolResult, *UIGetOutput, error) { + if result == nil { + return nil, nil, fmt.Errorf("ui_get %s returned no result", method) + } + if result.IsError { + return result, &UIGetOutput{Method: uiGetSchemaMethod(method)}, nil + } + if len(result.Content) != 1 { + return nil, nil, fmt.Errorf("ui_get %s returned %d content blocks; expected one", method, len(result.Content)) + } + text, ok := result.Content[0].(*mcp.TextContent) + if !ok { + return nil, nil, fmt.Errorf("ui_get %s returned non-text content", method) + } + output, err := decodeUIGetOutput(method, []byte(text.Text)) + if err != nil { + return nil, nil, fmt.Errorf("failed to decode ui_get %s output: %w", method, err) + } + return result, output, nil +} + +func uiGetSchemaMethod(method string) string { + switch method { + case "labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers": + return method + default: + return "labels" + } +} diff --git a/pkg/github/typed_copilot_ui_outputs_test.go b/pkg/github/typed_copilot_ui_outputs_test.go new file mode 100644 index 0000000000..6245f783bc --- /dev/null +++ b/pkg/github/typed_copilot_ui_outputs_test.go @@ -0,0 +1,597 @@ +package github + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/middleware" + "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type typedOutputRoundTripper func(*http.Request) (*http.Response, error) + +func (roundTripper typedOutputRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + return roundTripper(request) +} + +func typedCopilotUIClient(t *testing.T, fixture ...string) ToolDependencies { + t.Helper() + fixtureMode := "" + if len(fixture) > 0 { + fixtureMode = fixture[0] + } + httpClient := &http.Client{Transport: typedOutputRoundTripper(func(request *http.Request) (*http.Response, error) { + body := `[]` + status := http.StatusOK + switch request.URL.Path { + case "/graphql": + query, err := io.ReadAll(request.Body) + require.NoError(t, err) + switch { + case strings.Contains(string(query), "timelineItems"): + body = `{"data":{"repository":{"issue":{"timelineItems":{"nodes":[{"__typename":"CrossReferencedEvent","source":{"number":8,"url":"https://github.enterprise.example/owner/repo/pull/8","title":"Fix","state":"OPEN","createdAt":"` + time.Now().Add(time.Minute).UTC().Format(time.RFC3339) + `","author":{"login":"copilot-swe-agent"}}}]}}}}}` + case strings.Contains(string(query), "suggestedActors"): + body = `{"data":{"repository":{"suggestedActors":{"nodes":[{"id":"BOT","login":"copilot-swe-agent","__typename":"Bot"}],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}` + case strings.Contains(string(query), "issue(number:"): + body = `{"data":{"repository":{"id":"REPO","issue":{"id":"ISSUE","assignees":{"nodes":[]}}}}}` + case strings.Contains(string(query), "updateIssue(input:"): + body = `{"data":{"updateIssue":{"issue":{"id":"ISSUE","number":7,"url":"https://github.com/owner/repo/issues/7"}}}}` + if fixtureMode == "linked-enterprise" { + body = strings.ReplaceAll(body, "https://github.com/", "https://github.enterprise.example/") + } + case strings.Contains(string(query), "issueFields"): + if fixtureMode == "nonempty" { + body = `{"data":{"repository":{"issueFields":{"nodes":[{"__typename":"IssueFieldText","id":"FIELD_1","name":"Priority","description":"Issue priority","dataType":"text","visibility":"ALL"}]}}}}` + } else { + body = `{"data":{"repository":{"issueFields":{"nodes":[]}}}}` + } + case strings.Contains(string(query), "labels"): + if fixtureMode == "nonempty" { + body = `{"data":{"repository":{"labels":{"nodes":[{"id":"LABEL_1","name":"bug","color":"d73a4a","description":"Something isn't working"}],"totalCount":1,"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}` + } else { + body = `{"data":{"repository":{"labels":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}` + } + default: + return nil, assert.AnError + } + case "/orgs/owner/issue-types": + switch fixtureMode { + case "nonempty": + body = `[{"id":12,"name":"Bug","description":"A bug","color":"red"},null]` + case "issue-types-null": + body = `null` + } + case "/repos/owner/repo/assignees": + if fixtureMode == "nonempty" { + body = `[{"login":"octocat","avatar_url":"https://example.com/avatar.png"}]` + } + case "/repos/owner/repo/branches": + if fixtureMode == "nonempty" { + body = `[{"name":"main","protected":true,"commit":{"sha":"abc123"}}]` + } + case "/repos/owner/repo/milestones": + if fixtureMode == "nonempty" { + body = `[{"number":4,"title":"v1","description":"First release","state":"open","open_issues":2,"due_on":"2026-12-31T00:00:00Z"}]` + } + case "/repos/owner/repo/collaborators": + if fixtureMode == "nonempty" { + body = `[{"login":"octocat","avatar_url":"https://example.com/avatar.png"}]` + } + case "/repos/owner/repo/teams": + if fixtureMode == "nonempty" { + body = `[{"slug":"docs","name":"Docs"}]` + } + case "/repos/owner/repo/pulls/7/requested_reviewers": + status = http.StatusCreated + body = `{}` + } + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: request, + }, nil + })} + client := mustNewGHClient(t, httpClient) + return BaseDeps{ + Client: client, + GQLClient: githubv4.NewEnterpriseClient("https://api.github.com/graphql", httpClient), + } +} + +func typedCopilotUISession(t *testing.T, deps ToolDependencies, protocol string, pollConfigs ...PollConfig) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + translation := translations.NullTranslationHelper + tools := []inventory.ServerTool{ + AssignCopilotToIssue(translation), + AssignCopilotToIssueWithIntent(translation), + RequestCopilotReview(translation), + UIGet(translation), + } + registry, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "copilot-ui", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + registry.RegisterTools(context.Background(), server, deps) + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + switch request := request.(type) { + case *mcp.ListToolsRequest: + if request.Params == nil { + request.Params = &mcp.ListToolsParams{} + } + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + case *mcp.CallToolRequest: + if request.Params == nil { + request.Params = &mcp.CallToolParamsRaw{} + } + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + pollConfig := PollConfig{} + if len(pollConfigs) > 0 { + pollConfig = pollConfigs[0] + } + ctx = ContextWithPollConfig(ctx, pollConfig) + ctx = ghcontext.WithMCPMethodInfo(ctx, &ghcontext.MCPMethodInfo{ProtocolVersion: protocol}) + } + return next(ctx, method, request) + } + }) + version := protocol + if version == "" || version == "unknown" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, tool.Name) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + schemas[tool.Name] = resolved + } + return session, schemas +} + +func TestTypedCopilotAndUIWireOutputs(t *testing.T) { + protocols := []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} + tests := []struct { + name string + args map[string]any + text string + structured string + }{ + { + name: "assign_copilot_to_issue", + args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "7"}, + text: `{"issue_number":7,"issue_url":"https://github.com/owner/repo/issues/7","message":"successfully assigned copilot to issue - pull request pending","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.","owner":"owner","repo":"repo"}`, + structured: `{"issue_number":7,"issue_url":"https://github.com/owner/repo/issues/7","message":"successfully assigned copilot to issue - pull request pending","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.","owner":"owner","repo":"repo"}`, + }, + { + name: "assign_copilot_to_issue_with_intent", + args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "7", "rationale": " Clear acceptance criteria ", "confidence": "medium", "is_suggestion": "true"}, + text: `{"is_suggestion":true,"issue_number":7,"issue_url":"https://github.com/owner/repo/issues/7","message":"recorded pending copilot assignment suggestion","owner":"owner","repo":"repo"}`, + structured: `{"is_suggestion":true,"issue_number":7,"issue_url":"https://github.com/owner/repo/issues/7","message":"recorded pending copilot assignment suggestion","owner":"owner","repo":"repo"}`, + }, + { + name: "assign_copilot_to_issue_with_intent", + args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "7", "rationale": "Clear acceptance criteria", "confidence": "HIGH", "is_suggestion": false}, + text: `{"is_suggestion":false,"issue_number":7,"issue_url":"https://github.com/owner/repo/issues/7","message":"successfully assigned copilot to issue - pull request pending","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.","owner":"owner","repo":"repo"}`, + structured: `{"is_suggestion":false,"issue_number":7,"issue_url":"https://github.com/owner/repo/issues/7","message":"successfully assigned copilot to issue - pull request pending","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.","owner":"owner","repo":"repo"}`, + }, + { + name: "request_copilot_review", + args: map[string]any{"owner": "owner", "repo": "repo", "pullNumber": "7"}, + text: "", + structured: `{"status":"requested"}`, + }, + { + name: "ui_get", + args: map[string]any{"method": "labels", "owner": "owner", "repo": "repo"}, + text: `{"has_more":false,"labels":[],"totalCount":0}`, + structured: `{"method":"labels","labels":{"has_more":false,"labels":[],"totalCount":0}}`, + }, + { + name: "ui_get", + args: map[string]any{"method": "assignees", "owner": "owner", "repo": "repo"}, + text: `{"assignees":[],"has_more":false,"totalCount":0}`, + structured: `{"method":"assignees","assignees":{"assignees":[],"has_more":false,"totalCount":0}}`, + }, + { + name: "ui_get", + args: map[string]any{"method": "milestones", "owner": "owner", "repo": "repo"}, + text: `{"has_more":false,"milestones":[],"totalCount":0}`, + structured: `{"method":"milestones","milestones":{"has_more":false,"milestones":[],"totalCount":0}}`, + }, + { + name: "ui_get", + args: map[string]any{"method": "issue_types", "owner": "owner"}, + text: `[]`, + structured: `{"method":"issue_types","issue_types":[]}`, + }, + { + name: "ui_get", + args: map[string]any{"method": "branches", "owner": "owner", "repo": "repo"}, + text: `{"branches":[],"has_more":false,"totalCount":0}`, + structured: `{"method":"branches","branches":{"branches":[],"has_more":false,"totalCount":0}}`, + }, + { + name: "ui_get", + args: map[string]any{"method": "issue_fields", "owner": "owner", "repo": "repo"}, + text: `{"fields":[],"totalCount":0}`, + structured: `{"method":"issue_fields","issue_fields":{"fields":[],"totalCount":0}}`, + }, + { + name: "ui_get", + args: map[string]any{"method": "reviewers", "owner": "owner", "repo": "repo"}, + text: `{"has_more":false,"teams":[],"totalCount":0,"users":[]}`, + structured: `{"method":"reviewers","reviewers":{"has_more":false,"teams":[],"totalCount":0,"users":[]}}`, + }, + } + + for _, protocol := range protocols { + t.Run("protocol="+protocol, func(t *testing.T) { + session, schemas := typedCopilotUISession(t, typedCopilotUIClient(t), protocol) + for _, tc := range tests { + t.Run(tc.name+"/"+mustMarshalJSON(t, tc.args), func(t *testing.T) { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: tc.args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + schema := schemas[tc.name] + if schema == nil { + assert.Equal(t, tc.text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + return + } + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + assert.JSONEq(t, tc.structured, mustMarshalJSON(t, output)) + text := getTextResult(t, result).Text + assert.Equal(t, mustMarshalJSON(t, result.StructuredContent), text, + "modern success text should be the compact structured output") + }) + } + }) + } +} + +func TestTypedUIGetNonemptyWireOutputs(t *testing.T) { + session, schemas := typedCopilotUISession(t, typedCopilotUIClient(t, "nonempty"), inventory.ProtocolVersionMultiRoundTrip) + tests := []struct { + method string + args map[string]any + structured string + }{ + {method: "labels", args: map[string]any{"method": "labels", "owner": "owner", "repo": "repo"}, structured: `{"method":"labels","labels":{"labels":[{"id":"LABEL_1","name":"bug","color":"d73a4a","description":"Something isn't working"}],"totalCount":1,"has_more":false}}`}, + {method: "assignees", args: map[string]any{"method": "assignees", "owner": "owner", "repo": "repo"}, structured: `{"method":"assignees","assignees":{"assignees":[{"login":"octocat","avatar_url":"https://example.com/avatar.png"}],"totalCount":1,"has_more":false}}`}, + {method: "milestones", args: map[string]any{"method": "milestones", "owner": "owner", "repo": "repo"}, structured: `{"method":"milestones","milestones":{"milestones":[{"number":4,"title":"v1","description":"First release","state":"open","open_issues":2,"due_on":"2026-12-31"}],"totalCount":1,"has_more":false}}`}, + {method: "issue_types", args: map[string]any{"method": "issue_types", "owner": "owner"}, structured: `{"method":"issue_types","issue_types":[{"id":12,"name":"Bug","description":"A bug","color":"red"},null]}`}, + {method: "branches", args: map[string]any{"method": "branches", "owner": "owner", "repo": "repo"}, structured: `{"method":"branches","branches":{"branches":[{"name":"main","sha":"abc123","protected":true}],"totalCount":1,"has_more":false}}`}, + {method: "issue_fields", args: map[string]any{"method": "issue_fields", "owner": "owner", "repo": "repo"}, structured: `{"method":"issue_fields","issue_fields":{"fields":[{"id":"FIELD_1","name":"Priority","data_type":"text","description":"Issue priority"}],"totalCount":1}}`}, + {method: "reviewers", args: map[string]any{"method": "reviewers", "owner": "owner", "repo": "repo"}, structured: `{"method":"reviewers","reviewers":{"users":[{"login":"octocat","avatar_url":"https://example.com/avatar.png"}],"teams":[{"slug":"docs","name":"Docs","org":"owner"}],"totalCount":2,"has_more":false}}`}, + } + for _, tc := range tests { + t.Run(tc.method, func(t *testing.T) { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "ui_get", Arguments: tc.args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + schema := schemas["ui_get"] + require.NotNil(t, result.StructuredContent) + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + assert.JSONEq(t, tc.structured, mustMarshalJSON(t, output)) + assert.Equal(t, mustMarshalJSON(t, result.StructuredContent), getTextResult(t, result).Text) + }) + } +} + +func TestTypedCopilotAssignmentCanonicalURLs(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25"} { + t.Run(protocol, func(t *testing.T) { + session, schemas := typedCopilotUISession(t, typedCopilotUIClient(t, "linked-enterprise"), protocol, PollConfig{MaxAttempts: 1}) + for _, tc := range []struct { + name string + suggestion bool + }{ + {name: "assign_copilot_to_issue"}, + {name: "assign_copilot_to_issue_with_intent"}, + {name: "assign_copilot_to_issue_with_intent", suggestion: true}, + } { + t.Run(tc.name+"/"+mustMarshalJSON(t, tc.suggestion), func(t *testing.T) { + args := map[string]any{"owner": "owner", "repo": "repo", "issue_number": 7} + if tc.name == "assign_copilot_to_issue_with_intent" { + args["is_suggestion"] = tc.suggestion + args["rationale"] = "Clear acceptance criteria" + args["confidence"] = "HIGH" + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + var output map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &output)) + assert.Equal(t, "https://github.enterprise.example/owner/repo/issues/7", output["issue_url"]) + if tc.suggestion { + assert.NotContains(t, output, "pull_request") + } else { + pr, ok := output["pull_request"].(map[string]any) + require.True(t, ok, "%v", output) + assert.Equal(t, "https://github.enterprise.example/owner/repo/pull/8", pr["url"]) + } + if schema := schemas[tc.name]; schema != nil { + require.NoError(t, schema.Validate(output)) + assert.JSONEq(t, mustMarshalJSON(t, output), mustMarshalJSON(t, result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + } + }) + } + }) + } +} + +func TestTypedUIGetNullIssueTypesWireOutput(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25"} { + t.Run(protocol, func(t *testing.T) { + session, schemas := typedCopilotUISession(t, typedCopilotUIClient(t, "issue-types-null"), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "ui_get", + Arguments: map[string]any{"method": "issue_types", "owner": "owner"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + require.NotNil(t, result.StructuredContent) + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schemas["ui_get"].Validate(output)) + assert.JSONEq(t, `{"method":"issue_types","issue_types":null}`, mustMarshalJSON(t, output)) + assert.Equal(t, mustMarshalJSON(t, result.StructuredContent), getTextResult(t, result).Text) + } else { + assert.Equal(t, "null", getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedCopilotUIErrorsPreserveLegacyText(t *testing.T) { + protocols := []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} + tests := []struct { + name string + args map[string]any + text string + }{ + {name: "assign_copilot_to_issue", args: map[string]any{"repo": "repo", "issue_number": 7}, text: "missing required parameter: owner"}, + {name: "assign_copilot_to_issue_with_intent", args: map[string]any{"owner": "owner", "repo": "repo"}, text: "is_suggestion is required"}, + {name: "assign_copilot_to_issue_with_intent", args: map[string]any{"repo": "repo", "issue_number": 7, "rationale": strings.Repeat("x", 281), "confidence": "LOW", "is_suggestion": false}, text: "missing required parameter: owner"}, + {name: "assign_copilot_to_issue_with_intent", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": 7, "rationale": strings.Repeat("x", 281), "confidence": "LOW", "is_suggestion": false}, text: "rationale must be 280 characters or less"}, + {name: "assign_copilot_to_issue_with_intent", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": 7, "rationale": "valid", "confidence": "BAD", "is_suggestion": false}, text: "confidence must be one of: LOW, MEDIUM, HIGH"}, + {name: "request_copilot_review", args: map[string]any{"repo": "repo", "pullNumber": 7}, text: "missing required parameter: owner"}, + {name: "ui_get", args: map[string]any{"method": "labels", "owner": "owner"}, text: "missing required parameter: repo"}, + {name: "ui_get", args: map[string]any{"method": "unknown", "owner": "owner"}, text: "unknown method: unknown"}, + {name: "ui_get", args: map[string]any{"method": "unknown", "owner": "owner", "repo": true}, text: "unknown method: unknown"}, + {name: "ui_get", args: map[string]any{"method": "unknown"}, text: "missing required parameter: owner"}, + {name: "ui_get", args: map[string]any{"method": true, "owner": "owner"}, text: "parameter method is not of type string"}, + } + for _, protocol := range protocols { + t.Run("protocol="+protocol, func(t *testing.T) { + session, _ := typedCopilotUISession(t, typedCopilotUIClient(t), protocol) + for _, tc := range tests { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: tc.args}) + require.NoError(t, err) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + assert.Equal(t, tc.text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedUIGetHTTPScopesAndDispatch(t *testing.T) { + registry, err := inventory.NewBuilder().SetTools([]inventory.ServerTool{ + UIGet(translations.NullTranslationHelper), + }).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + scopes.SetToolScopeMapFromInventory(registry) + t.Cleanup(func() { scopes.SetGlobalToolScopeMap(nil) }) + + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} { + t.Run("protocol="+protocol, func(t *testing.T) { + session, _ := typedCopilotUISession(t, typedCopilotUIClient(t), protocol) + for _, tc := range []struct { + name string + args map[string]any + scopes []string + wantStatus int + wantError string + }{ + {name: "unknown method does not challenge", args: map[string]any{"method": "unknown", "owner": "owner", "repo": "repo"}, scopes: []string{"gist"}, wantStatus: http.StatusOK, wantError: "unknown method: unknown"}, + {name: "unknown method validates owner first", args: map[string]any{"method": "unknown", "repo": "repo"}, scopes: []string{"gist"}, wantStatus: http.StatusOK, wantError: "missing required parameter: owner"}, + {name: "labels cannot be overridden to issue types", args: map[string]any{"method": "labels", "owner": "owner", "repo": "repo", "_compat_method": "issue_types"}, scopes: []string{"repo"}, wantStatus: http.StatusOK}, + {name: "labels cannot be overridden to reviewers", args: map[string]any{"method": "labels", "owner": "owner", "repo": "repo", "_compat_method": "reviewers"}, scopes: []string{"repo"}, wantStatus: http.StatusOK}, + {name: "issue types still require read org", args: map[string]any{"method": "issue_types", "owner": "owner", "_compat_method": "labels"}, scopes: []string{"repo"}, wantStatus: http.StatusForbidden}, + {name: "labels still require repo", args: map[string]any{"method": "labels", "owner": "owner", "repo": "repo"}, scopes: []string{"gist"}, wantStatus: http.StatusForbidden}, + } { + t.Run(tc.name, func(t *testing.T) { + raw, err := json.Marshal(tc.args) + require.NoError(t, err) + called := false + handler := middleware.WithScopeChallenge(&oauth.Config{}, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + result, err := session.CallTool(r.Context(), &mcp.CallToolParams{Name: "ui_get", Arguments: tc.args}) + require.NoError(t, err) + if tc.wantError != "" { + require.True(t, result.IsError) + assert.Equal(t, tc.wantError, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } else { + require.False(t, result.IsError) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + assert.JSONEq(t, `{"method":"labels","labels":{"labels":[],"totalCount":0,"has_more":false}}`, getTextResult(t, result).Text) + } else { + assert.Equal(t, `{"has_more":false,"labels":[],"totalCount":0}`, getTextResult(t, result).Text) + } + } + require.NoError(t, json.NewEncoder(w).Encode(result)) + })) + request := httptest.NewRequest(http.MethodPost, "/mcp", nil) + ctx := ghcontext.WithTokenInfo(request.Context(), &ghcontext.TokenInfo{TokenType: utils.TokenTypeOAuthAccessToken}) + ctx = ghcontext.WithTokenScopes(ctx, tc.scopes) + ctx = ghcontext.WithMCPMethodInfo(ctx, &ghcontext.MCPMethodInfo{ + Method: "tools/call", ItemName: "ui_get", RawArguments: raw, ProtocolVersion: protocol, + }) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request.WithContext(ctx)) + assert.Equal(t, tc.wantStatus, response.Code) + assert.Equal(t, tc.wantStatus == http.StatusOK, called) + if tc.wantStatus == http.StatusOK { + assert.Empty(t, response.Header().Get("WWW-Authenticate")) + } else { + assert.Contains(t, response.Header().Get("WWW-Authenticate"), "insufficient_scope") + } + }) + } + }) + } +} + +func TestTypedUIGetUnionOutputs(t *testing.T) { + cases := []struct { + method string + raw string + typed string + }{ + {method: "labels", raw: `{"labels":[{"id":"L","name":"bug","color":"red","description":""}],"totalCount":1,"has_more":false}`, typed: `{"method":"labels","labels":{"labels":[{"id":"L","name":"bug","color":"red","description":""}],"totalCount":1,"has_more":false}}`}, + {method: "assignees", raw: `{"assignees":[{"login":"octocat","avatar_url":"https://example.com/avatar.png"}],"totalCount":1,"has_more":false}`, typed: `{"method":"assignees","assignees":{"assignees":[{"login":"octocat","avatar_url":"https://example.com/avatar.png"}],"totalCount":1,"has_more":false}}`}, + {method: "milestones", raw: `{"milestones":[{"number":4,"title":"v1","description":"","state":"open","open_issues":2,"due_on":""}],"totalCount":1,"has_more":false}`, typed: `{"method":"milestones","milestones":{"milestones":[{"number":4,"title":"v1","description":"","state":"open","open_issues":2,"due_on":""}],"totalCount":1,"has_more":false}}`}, + {method: "issue_types", raw: `[{"id":1,"name":"Bug"}]`, typed: `{"method":"issue_types","issue_types":[{"id":1,"name":"Bug"}]}`}, + {method: "issue_types", raw: `null`, typed: `{"method":"issue_types","issue_types":null}`}, + {method: "branches", raw: `{"branches":[{"name":"main","sha":"abc","protected":true}],"totalCount":1,"has_more":false}`, typed: `{"method":"branches","branches":{"branches":[{"name":"main","sha":"abc","protected":true}],"totalCount":1,"has_more":false}}`}, + {method: "issue_fields", raw: `{"fields":[{"id":"F","name":"Priority","data_type":"single_select","description":"","options":[]}],"totalCount":1}`, typed: `{"method":"issue_fields","issue_fields":{"fields":[{"id":"F","name":"Priority","data_type":"single_select","description":"","options":[]}],"totalCount":1}}`}, + {method: "reviewers", raw: `{"users":[{"login":"octocat","avatar_url":""}],"teams":[{"slug":"docs","name":"Docs","org":"owner"}],"totalCount":2,"has_more":false}`, typed: `{"method":"reviewers","reviewers":{"users":[{"login":"octocat","avatar_url":""}],"teams":[{"slug":"docs","name":"Docs","org":"owner"}],"totalCount":2,"has_more":false}}`}, + } + schema, err := uiGetOutputSchema().Resolve(nil) + require.NoError(t, err) + for _, tc := range cases { + t.Run(tc.method, func(t *testing.T) { + output, err := decodeUIGetOutput(tc.method, []byte(tc.raw)) + require.NoError(t, err) + encoded, err := json.Marshal(output) + require.NoError(t, err) + assert.JSONEq(t, tc.typed, string(encoded)) + var value any + require.NoError(t, json.Unmarshal(encoded, &value)) + require.NoError(t, schema.Validate(value)) + }) + } +} + +func TestToolDefinitionsUseConcreteOutputTypes(t *testing.T) { + for _, tool := range AllTools(translations.NullTranslationHelper) { + registerTyped := reflect.ValueOf(tool).FieldByName("registerTyped") + require.True(t, registerTyped.IsValid(), "tool %q has no output registration metadata", tool.Tool.Name) + assert.False(t, registerTyped.IsNil(), "tool %q uses an untyped output registration", tool.Tool.Name) + } +} + +func TestTypedCopilotOutputSchemas(t *testing.T) { + cases := []struct { + name string + schema *jsonschema.Schema + raw string + }{ + { + name: "assign_copilot_to_issue", + schema: assignCopilotToIssueOutputSchema(), + raw: `{"issue_number":7,"issue_url":"https://github.enterprise.example/owner/repo/issues/7","message":"successfully assigned copilot to issue - pull request pending","note":"pending","owner":"owner","pull_request":{"number":8,"state":"OPEN","title":"Fix","url":"https://github.enterprise.example/owner/repo/pull/8"},"repo":"repo"}`, + }, + { + name: "assign_copilot_to_issue_with_intent", + schema: assignCopilotToIssueWithIntentOutputSchema(), + raw: `{"is_suggestion":true,"issue_number":7,"issue_url":"https://github.enterprise.example/owner/repo/issues/7","message":"suggested","owner":"owner","repo":"repo"}`, + }, + { + name: "request_copilot_review", + schema: copilotReviewOutputSchema(), + raw: `{"status":"requested"}`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, "object", tc.schema.Type) + require.NotEmpty(t, tc.schema.Properties) + assert.Nil(t, tc.schema.AnyOf) + assert.Nil(t, tc.schema.OneOf) + resolved, err := tc.schema.Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(tc.raw), &value)) + require.NoError(t, resolved.Validate(value)) + }) + } + assert.Contains(t, mustMarshalJSON(t, assignCopilotToIssueOutputSchema()), `"url"`) + assert.Contains(t, mustMarshalJSON(t, assignCopilotToIssueWithIntentOutputSchema()), `"url"`) + reviewSchema, err := copilotReviewOutputSchema().Resolve(nil) + require.NoError(t, err) + require.NoError(t, reviewSchema.Validate(map[string]any{"status": "requested"})) + require.NoError(t, reviewSchema.Validate(map[string]any{"status": ""})) + assert.Error(t, reviewSchema.Validate(map[string]any{})) +} + +func TestTypedUIGetOutputSchemaDiscriminator(t *testing.T) { + schema := uiGetOutputSchema() + require.Equal(t, "object", schema.Type) + require.Equal(t, []string{"method"}, schema.Required) + assert.Nil(t, schema.AnyOf) + assert.Len(t, schema.OneOf, len(schema.Properties["method"].Enum)) + require.Contains(t, schema.Properties, "method") + assert.Equal(t, []any{"labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers"}, schema.Properties["method"].Enum) + for _, property := range []string{"labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers"} { + require.Contains(t, schema.Properties, property) + assert.NotEqual(t, `{}`, mustMarshalJSON(t, schema.Properties[property]), property) + } + assert.NotContains(t, mustMarshalJSON(t, schema), `"url"`) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + for _, raw := range []string{ + `{"method":"labels","labels":{"labels":[],"totalCount":0,"has_more":false}}`, + `{"method":"issue_types","issue_types":[]}`, + } { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + require.NoError(t, resolved.Validate(value)) + } + for _, raw := range []string{`{}`, `{"method":"unsupported"}`, `{"method":"labels","labels":true}`} { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + assert.Error(t, resolved.Validate(value), raw) + } +} diff --git a/pkg/github/typed_discussion_notification_outputs_test.go b/pkg/github/typed_discussion_notification_outputs_test.go new file mode 100644 index 0000000000..de7f27817a --- /dev/null +++ b/pkg/github/typed_discussion_notification_outputs_test.go @@ -0,0 +1,317 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func discussionNotificationTools() []inventory.ServerTool { + t := translations.NullTranslationHelper + return []inventory.ServerTool{ + ListNotifications(t), DismissNotification(t), MarkAllNotificationsRead(t), + GetNotificationDetails(t), ManageNotificationSubscription(t), + ManageRepositoryNotificationSubscription(t), ListDiscussions(t), + GetDiscussion(t), GetDiscussionComments(t), DiscussionCommentWrite(t), + ListDiscussionCategories(t), + } +} + +func typedDiscussionNotificationDeps(t *testing.T) BaseDeps { + t.Helper() + notification := &github.Notification{ + ID: new("123"), Reason: new("mention"), Unread: new(true), + Subject: &github.NotificationSubject{Title: new("A discussion"), Type: new("Discussion")}, + Repository: &github.Repository{ + ID: new(int64(42)), FullName: new("owner/repo"), + Description: new("legacy-only repository detail"), + }, + } + subscription := &github.Subscription{Subscribed: new(true), Ignored: new(false)} + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + var data any + comment := map[string]any{"id": "DC_123", "url": "https://github.com/owner/repo/discussions/1#discussioncomment-123"} + discussion := map[string]any{"id": "D_1", "url": "https://github.com/owner/repo/discussions/1"} + pageInfo := map[string]any{"hasNextPage": true, "hasPreviousPage": false, "startCursor": "start", "endCursor": "end"} + switch { + case strings.Contains(req.Query, "addDiscussionComment("): + data = map[string]any{"addDiscussionComment": map[string]any{"comment": comment}} + case strings.Contains(req.Query, "updateDiscussionComment("): + data = map[string]any{"updateDiscussionComment": map[string]any{"comment": comment}} + case strings.Contains(req.Query, "deleteDiscussionComment("): + data = map[string]any{"deleteDiscussionComment": map[string]any{"comment": comment}} + case strings.Contains(req.Query, "unmarkDiscussionCommentAsAnswer("): + data = map[string]any{"unmarkDiscussionCommentAsAnswer": map[string]any{"discussion": discussion}} + case strings.Contains(req.Query, "markDiscussionCommentAsAnswer("): + data = map[string]any{"markDiscussionCommentAsAnswer": map[string]any{"discussion": discussion}} + case strings.Contains(req.Query, "node(id:"): + id := "D_1" + if req.Variables["replyToID"] == "wrong-discussion" { + id = "D_2" + } + data = map[string]any{"node": map[string]any{ + "id": "DC_123", "discussion": map[string]any{"id": id}, + }} + case strings.Contains(req.Query, "discussionCategories("): + assert.Equal(t, float64(25), req.Variables["first"]) + assert.Equal(t, ".github", req.Variables["repo"]) + data = map[string]any{"repository": map[string]any{"discussionCategories": map[string]any{ + "nodes": []any{map[string]any{"id": "CAT_1", "name": "General"}}, "pageInfo": pageInfo, "totalCount": 1, + }}} + case strings.Contains(req.Query, "discussions("): + assert.Equal(t, float64(30), req.Variables["first"]) + assert.Equal(t, ".github", req.Variables["repo"]) + data = map[string]any{"repository": map[string]any{"discussions": map[string]any{ + "nodes": []any{map[string]any{ + "number": 1, "title": "A title", "url": "https://github.com/owner/repo/discussions/1", + "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-02T00:00:00Z", + "author": map[string]any{"login": "octocat"}, "category": map[string]any{"name": "General"}, + }}, "pageInfo": pageInfo, "totalCount": 1, + }}} + case strings.Contains(req.Query, "comments("): + assert.Equal(t, float64(30), req.Variables["first"]) + assert.Equal(t, float64(1), req.Variables["discussionNumber"]) + node := map[string]any{"id": "DC_123", "body": "A comment", "isAnswer": true} + if strings.Contains(req.Query, "replies(") { + node["replies"] = map[string]any{ + "nodes": []any{map[string]any{"id": "DC_reply", "body": "A reply", "isAnswer": false}}, "totalCount": 1, + } + } + data = map[string]any{"repository": map[string]any{"discussion": map[string]any{"comments": map[string]any{ + "nodes": []any{node}, "pageInfo": pageInfo, "totalCount": 1, + }}}} + case strings.Contains(req.Query, "discussion(number:") && strings.Contains(req.Query, "{id}"): + data = map[string]any{"repository": map[string]any{"discussion": map[string]any{"id": "D_1"}}} + case strings.Contains(req.Query, "discussion(number:"): + assert.Equal(t, float64(1), req.Variables["discussionNumber"]) + data = map[string]any{"repository": map[string]any{"discussion": map[string]any{ + "number": 1, "title": "A title", "body": "A body", + "url": "https://github.com/owner/repo/discussions/1", "createdAt": "2026-01-01T00:00:00Z", + "closed": false, "isAnswered": true, "answerChosenAt": "2026-01-02T00:00:00Z", + "category": map[string]any{"name": "General"}, + }}} + default: + t.Errorf("unexpected GraphQL query: %s", req.Query) + } + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"data": data})) + })}} + return BaseDeps{ + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + GQLClient: githubv4.NewClient(gql), + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{Private: new(true)}), + GetNotifications: mockResponse(t, http.StatusOK, []*github.Notification{notification}), + GetReposNotificationsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.Notification{notification}), + GetNotificationsThreadsByThreadID: mockResponse(t, http.StatusOK, notification), + PatchNotificationsThreadsByThreadID: mockResponse(t, http.StatusOK, nil), + DeleteNotificationsThreadsByThreadID: mockResponse(t, http.StatusNoContent, nil), + PutNotifications: mockResponse(t, http.StatusOK, nil), + PutReposNotificationsByOwnerByRepo: mockResponse(t, http.StatusOK, nil), + PutNotificationsThreadsSubscriptionByThreadID: mockResponse(t, http.StatusOK, subscription), + DeleteNotificationsThreadsSubscriptionByThreadID: mockResponse(t, http.StatusNoContent, nil), + PutReposSubscriptionByOwnerByRepo: mockResponse(t, http.StatusOK, subscription), + DeleteReposSubscriptionByOwnerByRepo: mockResponse(t, http.StatusNoContent, nil), + })), + } +} + +func TestTypedDiscussionNotificationOutputs(t *testing.T) { + const notificationText = `{"id":"123","repository":{"id":42,"full_name":"owner/repo","description":"legacy-only repository detail"},"subject":{"title":"A discussion","type":"Discussion"},"reason":"mention","unread":true}` + const commentText = `{"id":"DC_123","url":"https://github.com/owner/repo/discussions/1#discussioncomment-123"}` + const answerText = `{"discussionID":"D_1","discussionURL":"https://github.com/owner/repo/discussions/1"}` + const pageText = `{"endCursor":"end","hasNextPage":true,"hasPreviousPage":false,"startCursor":"start"}` + tests := []struct { + name string + args map[string]any + text string + }{ + {"list_notifications", map[string]any{"page": "0", "perPage": "0"}, "[" + notificationText + "]"}, + {"list_notifications", map[string]any{"owner": "owner", "repo": "repo", "since": "2026-01-01T00:00:00Z"}, "[" + notificationText + "]"}, + {"get_notification_details", map[string]any{"notificationID": "123"}, notificationText}, + {"dismiss_notification", map[string]any{"threadID": "123", "state": "read"}, "Notification marked as read"}, + {"dismiss_notification", map[string]any{"threadID": "123", "state": "done"}, "Notification marked as done"}, + {"mark_all_notifications_read", map[string]any{"lastReadAt": "2026-01-01T00:00:00Z"}, "All notifications marked as read"}, + {"mark_all_notifications_read", map[string]any{"owner": "owner", "repo": "repo", "lastReadAt": "2026-01-01T00:00:00Z"}, "All notifications marked as read"}, + {"list_discussions", map[string]any{"owner": "owner", "perPage": "30", "after": "cursor", "orderBy": "UPDATED_AT", "direction": "DESC", "category": "CAT_1"}, + `{"discussions":[{"category":{"name":"General"},"html_url":"https://github.com/owner/repo/discussions/1","number":1,"title":"A title","user":{"login":"octocat"},"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z"}],"pageInfo":` + pageText + `,"totalCount":1}`}, + {"get_discussion", map[string]any{"OWNER": "owner", "REPO": "repo", "DISCUSSIONNUMBER": "1"}, + `{"answerChosenAt":"2026-01-02T00:00:00Z","body":"A body","category":{"name":"General"},"closed":false,"createdAt":"2026-01-01T00:00:00Z","isAnswered":true,"number":1,"title":"A title","url":"https://github.com/owner/repo/discussions/1"}`}, + {"get_discussion_comments", map[string]any{"owner": "owner", "repo": "repo", "discussionNumber": "1"}, + `{"comments":[{"id":"DC_123","body":"A comment","isAnswer":true}],"pageInfo":` + pageText + `,"totalCount":1}`}, + {"get_discussion_comments", map[string]any{"owner": "owner", "repo": "repo", "discussionNumber": 1.9, "includeReplies": true, "perPage": "0"}, + `{"comments":[{"id":"DC_123","body":"A comment","isAnswer":true,"replies":[{"id":"DC_reply","body":"A reply"}],"replyTotalCount":1}],"pageInfo":` + pageText + `,"totalCount":1}`}, + {"list_discussion_categories", map[string]any{"owner": "owner"}, + `{"categories":[{"id":"CAT_1","name":"General"}],"pageInfo":` + pageText + `,"totalCount":1}`}, + } + for _, name := range []string{"manage_notification_subscription", "manage_repository_notification_subscription"} { + for _, action := range []string{"ignore", "watch", "delete"} { + args := map[string]any{"action": action, "notificationID": "123", "owner": "owner", "repo": "repo"} + text := `{"subscribed":true,"ignored":false}` + if action == "delete" { + text = "Notification subscription deleted" + if name == "manage_repository_notification_subscription" { + text = "Repository subscription deleted" + } + } + tests = append(tests, struct { + name string + args map[string]any + text string + }{name, args, text}) + } + } + for _, method := range []string{"add", "reply", "update", "delete", "mark_answer", "unmark_answer"} { + args := map[string]any{"method": method, "owner": "owner", "repo": "repo", "discussionNumber": "1", "body": "New body", "commentNodeID": "DC_123"} + text := commentText + if method == "mark_answer" || method == "unmark_answer" { + text = answerText + } + tests = append(tests, struct { + name string + args map[string]any + text string + }{"discussion_comment_write", args, text}) + } + + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocol, func(t *testing.T) { + deps := typedDiscussionNotificationDeps(t) + server := mcp.NewServer(&mcp.Implementation{Name: "typed-discussion-notification-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + for _, tool := range discussionNotificationTools() { + tool.RegisterFunc(server, deps) + } + client := connectCommentVisibilityClient(t, server, protocol) + list, err := client.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 11) + byName := map[string]*mcp.Tool{} + for _, tool := range list.Tools { + byName[tool.Name] = tool + if protocol == "2025-11-25" { + assert.Nil(t, tool.OutputSchema, tool.Name) + } else { + require.NotNil(t, tool.OutputSchema, tool.Name) + canonical := tool.OutputSchema + for _, definition := range discussionNotificationTools() { + if definition.Tool.Name == tool.Name { + assert.JSONEq(t, mustMarshalJSON(t, definition.Tool.OutputSchema), mustMarshalJSON(t, canonical)) + require.NoError(t, toolsnaps.Test(definition.Tool.Name, definition.Tool)) + } + } + } + } + for i, tc := range tests { + t.Run(tc.name+"/"+string(rune('A'+i)), func(t *testing.T) { + result, err := client.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: tc.args}) + require.NoError(t, err) + require.False(t, result.IsError, "%s", result) + require.Len(t, result.Content, 1, "text must not duplicate structured content") + switch tc.name { + case "get_notification_details": + assert.JSONEq(t, mustMarshalJSON(t, ifc.LabelNotificationDetails()), mustMarshalJSON(t, result.Meta["ifc"])) + case "list_discussions", "get_discussion", "get_discussion_comments": + assert.JSONEq(t, mustMarshalJSON(t, ifc.LabelRepoUserContent(true)), mustMarshalJSON(t, result.Meta["ifc"])) + case "list_discussion_categories": + assert.JSONEq(t, mustMarshalJSON(t, ifc.LabelRepoMetadata(true)), mustMarshalJSON(t, result.Meta["ifc"])) + } + if protocol == "2025-11-25" { + assert.Equal(t, tc.text, getTextResult(t, result).Text, "legacy text must remain byte-equivalent") + assert.Nil(t, result.StructuredContent) + return + } + require.NotNil(t, result.StructuredContent) + if json.Valid([]byte(tc.text)) { + assert.JSONEq(t, mustMarshalJSON(t, result.StructuredContent), getTextResult(t, result).Text, + "modern JSON text must serialize the same compact DTO as structuredContent") + } else { + assert.Equal(t, tc.text, getTextResult(t, result).Text, "plain status and deletion messages remain unchanged") + } + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, byName[tc.name].OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, resolved.Validate(output), tc.name) + assert.NotContains(t, mustMarshalJSON(t, output), "legacy-only repository detail") + if tc.name == "get_discussion" { + assert.Contains(t, mustMarshalJSON(t, output), `"html_url"`) + assert.NotContains(t, mustMarshalJSON(t, output), `"url"`) + } + }) + } + for _, tc := range []struct { + name string + args map[string]any + text string + }{ + {"list_notifications", map[string]any{"since": "invalid"}, "invalid since time format, should be RFC3339/ISO8601:"}, + {"list_notifications", map[string]any{"before": "invalid"}, "invalid before time format, should be RFC3339/ISO8601:"}, + {"mark_all_notifications_read", map[string]any{"lastReadAt": "invalid"}, "invalid lastReadAt time format, should be RFC3339/ISO8601:"}, + {"discussion_comment_write", map[string]any{"method": "reply", "owner": "owner", "repo": "repo", "discussionNumber": 1, "body": "reply", "commentNodeID": "wrong-discussion"}, `does not belong to discussion #1 in owner/repo`}, + {"discussion_comment_write", map[string]any{"method": "delete", "commentNodeID": " "}, "commentNodeID cannot be blank"}, + {"discussion_comment_write", map[string]any{"method": "add", "owner": "owner", "repo": "repo", "discussionNumber": 1}, "missing required parameter: body"}, + } { + result, err := client.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: tc.args}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent, "errors must not contain a success DTO") + assert.Contains(t, getTextResult(t, result).Text, tc.text) + } + }) + } +} + +func TestDiscussionNotificationUnionsRejectImpossibleResults(t *testing.T) { + for _, tc := range []struct { + name string + schema *jsonschema.Schema + valid []string + invalid []string + }{ + { + "comment write", discussionCommentWriteOutputSchema(), + []string{`{"id":"","url":""}`, `{"discussionID":"","discussionURL":""}`}, + []string{`{}`, `{"id":"123"}`, `{"discussionID":"D_1"}`, `{"id":"123","url":"","discussionID":"D_1","discussionURL":""}`, `{"message":"deleted"}`}, + }, + { + "subscription", notificationSubscriptionResultSchema(), + []string{`{"subscription":{}}`, `{"message":"deleted"}`}, + []string{`{}`, `{"subscription":{},"message":"deleted"}`, `{"subscription":null}`, `{"subscribed":true}`}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + resolved, err := tc.schema.Resolve(nil) + require.NoError(t, err) + for _, raw := range tc.valid { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + assert.NoError(t, resolved.Validate(value), raw) + } + for _, raw := range tc.invalid { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + assert.Error(t, resolved.Validate(value), raw) + } + }) + } +} diff --git a/pkg/github/typed_git_gist_outputs_test.go b/pkg/github/typed_git_gist_outputs_test.go new file mode 100644 index 0000000000..ef411295df --- /dev/null +++ b/pkg/github/typed_git_gist_outputs_test.go @@ -0,0 +1,391 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var typedGitGistProtocols = []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} + +func gitGistTypedSession(t *testing.T, deps ToolDependencies, protocol string) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + tr := translations.NullTranslationHelper + tools := []inventory.ServerTool{GetRepositoryTree(tr), ListGists(tr), GetGist(tr), CreateGist(tr), UpdateGist(tr)} + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "git-gist", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + if protocol == "" || protocol == "unknown" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch req := req.(type) { + case *mcp.ListToolsRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + case *mcp.CallToolRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + } + return next(ctx, method, req) + } + }) + } + version := protocol + if version == "" || version == "unknown" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, tool.Name) + for _, canonical := range tools { + if canonical.Tool.Name == tool.Name { + assert.JSONEq(t, mustMarshalJSON(t, canonical.Tool.OutputSchema), mustMarshalJSON(t, tool.OutputSchema)) + } + } + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + schemas[tool.Name] = resolved + } + return session, schemas +} + +type gitGistCase struct { + name string + args map[string]any + method string + path string + query string + body string + status int + text string + structured string +} + +func gitGistDeps(t *testing.T, current **gitGistCase, apiError bool, private ...bool) BaseDeps { + t.Helper() + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tc := *current + if r.URL.Path == "/repos/owner/repo" { + _, _ = fmt.Fprintf(w, `{"default_branch":"main","private":%t}`, len(private) > 0 && private[0]) + return + } + assert.Equal(t, tc.method, r.Method) + assert.Equal(t, tc.path, r.URL.Path) + assert.Equal(t, tc.query, r.URL.RawQuery) + switch tc.name { + case "create_gist": + var payload struct { + Description string `json:"description"` + Public bool `json:"public"` + Files map[string]struct { + Content string `json:"content"` + } `json:"files"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, tc.args["description"], payload.Description) + assert.Equal(t, tc.args["public"], payload.Public) + assert.Equal(t, tc.args["content"], payload.Files[tc.args["filename"].(string)].Content) + case "update_gist": + var payload struct { + Description *string `json:"description"` + Files map[string]struct { + Filename string `json:"filename"` + Content string `json:"content"` + } `json:"files"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + if description, present := tc.args["description"]; present { + require.NotNil(t, payload.Description) + assert.Equal(t, description, *payload.Description) + } else { + assert.Nil(t, payload.Description) + } + file := payload.Files[tc.args["filename"].(string)] + assert.Equal(t, tc.args["filename"], file.Filename) + assert.Equal(t, tc.args["content"], file.Content) + } + if apiError { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + return + } + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })}} + return BaseDeps{Client: mustNewGHClient(t, client), ContentWindowSize: 100, RepoAccessCache: stubRepoAccessCache(nil, time.Minute)} +} + +const ( + gistBody = `{"id":"abc","description":"d","public":true,"comments":2,"html_url":"https://gist.github.com/abc","created_at":"2026-01-01T00:00:00Z","owner":{"login":"octocat","id":1,"url":"https://api.github.com/users/octocat","html_url":"https://github.com/octocat","node_id":"user-node"},"files":{"a.txt":{"filename":"a.txt","size":3,"content":"hey","raw_url":"https://gist.githubusercontent.com/raw"}},"node_id":"gist-node","git_pull_url":"https://gist.github.com/abc.git","git_push_url":"https://gist.github.com/abc.git"}` + gistText = `{"id":"abc","description":"d","public":true,"owner":{"login":"octocat","id":1,"node_id":"user-node","html_url":"https://github.com/octocat","url":"https://api.github.com/users/octocat"},"files":{"a.txt":{"size":3,"filename":"a.txt","raw_url":"https://gist.githubusercontent.com/raw","content":"hey"}},"comments":2,"html_url":"https://gist.github.com/abc","git_pull_url":"https://gist.github.com/abc.git","git_push_url":"https://gist.github.com/abc.git","created_at":"2026-01-01T00:00:00Z","node_id":"gist-node"}` + gistStructured = `{"id":"abc","description":"d","public":true,"owner":{"login":"octocat","id":1,"profile_url":"https://github.com/octocat"},"files":{"a.txt":{"size":3,"filename":"a.txt","content":"hey","raw_url":"https://gist.githubusercontent.com/raw"}},"comments":2,"html_url":"https://gist.github.com/abc","git_pull_url":"https://gist.github.com/abc.git","created_at":"2026-01-01T00:00:00Z"}` + treeBody = `{"sha":"t1","truncated":false,"tree":[{"path":"src/a.go","mode":"100644","type":"blob","sha":"s1","size":5,"url":"u1"},{"path":"src","mode":"040000","type":"tree","sha":"s2","url":"u2"},{"path":"README.md","mode":"100644","type":"blob","sha":"s3","size":1,"url":"u3"}]}` + minimalOK = `{"id":"abc","url":"https://gist.github.com/abc"}` +) + +func gitGistWireCases() []gitGistCase { + return []gitGistCase{ + {name: "get_repository_tree", args: map[string]any{"owner": "owner", "repo": "repo", "recursive": true, "path_filter": "src"}, method: "GET", path: "/repos/owner/repo/git/trees/main", query: "recursive=1", body: treeBody, status: 200, + text: `{"sha":"t1","truncated":false,"tree":[{"path":"src/a.go","type":"blob","size":5,"mode":"100644","sha":"s1","url":"u1"},{"path":"src","type":"tree","mode":"040000","sha":"s2","url":"u2"}],"tree_sha":"main","owner":"owner","repo":"repo","recursive":true,"count":2}`, + structured: `{"sha":"t1","truncated":false,"tree":[{"path":"src/a.go","type":"blob","size":5,"mode":"100644","sha":"s1"},{"path":"src","type":"tree","mode":"040000","sha":"s2"}],"tree_sha":"main","owner":"owner","repo":"repo","recursive":true,"count":2}`}, + {name: "get_repository_tree", args: map[string]any{"owner": "owner", "repo": "repo", "tree_sha": "abc", "path_filter": "none"}, method: "GET", path: "/repos/owner/repo/git/trees/abc", body: treeBody, status: 200, + text: `{"sha":"t1","truncated":false,"tree":[],"tree_sha":"abc","owner":"owner","repo":"repo","recursive":false,"count":0}`}, + {name: "list_gists", args: map[string]any{"username": "octocat", "since": "2026-01-01T00:00:00Z", "page": "2e0", "perPage": "5"}, method: "GET", path: "/users/octocat/gists", query: "page=2&per_page=5&since=2026-01-01T00%3A00%3A00Z", body: `[` + gistBody + `]`, status: 200, text: `[` + gistText + `]`, structured: `[` + gistStructured + `]`}, + {name: "list_gists", args: map[string]any{"page": 0, "perPage": 0}, method: "GET", path: "/gists", query: "page=1&per_page=30", body: `[]`, status: 200, text: `[]`}, + {name: "list_gists", args: map[string]any{"page": -1, "perPage": 101}, method: "GET", path: "/gists", query: "page=-1&per_page=101", body: `[]`, status: 200, text: `[]`}, + {name: "list_gists", args: map[string]any{"page": "-1", "perPage": "-2"}, method: "GET", path: "/gists", query: "page=-1&per_page=-2", body: `[]`, status: 200, text: `[]`}, + {name: "list_gists", args: nil, method: "GET", path: "/gists", query: "page=1&per_page=30", body: `null`, status: 200, text: `null`}, + {name: "get_gist", args: map[string]any{"gist_id": "abc"}, method: "GET", path: "/gists/abc", body: gistBody, status: 200, text: gistText, structured: gistStructured}, + {name: "get_gist", args: map[string]any{"gist_id": "abc"}, method: "GET", path: "/gists/abc", body: `{"public":false,"description":"","files":{"empty":{"filename":"","content":"","size":0,"language":"","type":""}}}`, status: 200, + text: `{"description":"","public":false,"files":{"empty":{"size":0,"filename":"","language":"","type":"","content":""}}}`}, + {name: "get_gist", args: map[string]any{"gist_id": "abc"}, method: "GET", path: "/gists/abc", body: `{}`, status: 200, text: `{}`}, + {name: "get_gist", args: map[string]any{"gist_id": "abc"}, method: "GET", path: "/gists/abc", body: `null`, status: 200, text: `null`}, + {name: "get_gist", args: map[string]any{"gist_id": "abc"}, method: "GET", path: "/gists/abc", + body: `{"created_at":"2026-01-01T00:00:00.123456789+02:00","updated_at":"2026-01-02T12:00:00Z"}`, status: 200, + text: `{"created_at":"2026-01-01T00:00:00.123456789+02:00","updated_at":"2026-01-02T12:00:00Z"}`}, + {name: "list_gists", method: "GET", path: "/gists", query: "page=1&per_page=30", body: `[null,{}]`, status: 200, text: `[null,{}]`}, + {name: "create_gist", args: map[string]any{"filename": "a.txt", "content": "hey", "description": "d", "public": true}, method: "POST", path: "/gists", body: gistBody, status: 201, text: minimalOK}, + {name: "update_gist", args: map[string]any{"gist_id": "abc", "filename": "a.txt", "content": "hey"}, method: "PATCH", path: "/gists/abc", body: gistBody, status: 200, text: minimalOK}, + {name: "update_gist", args: map[string]any{"gist_id": "abc", "filename": "a.txt", "content": "hey", "description": ""}, method: "PATCH", path: "/gists/abc", body: gistBody, status: 200, text: minimalOK}, + } +} + +func assertGitGistResult(t *testing.T, result *mcp.CallToolResult, schema *jsonschema.Resolved, text string, isError bool, structured ...string) { + t.Helper() + require.Equal(t, isError, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + actual := getTextResult(t, result).Text + if schema == nil || isError { + assert.Equal(t, text, actual) + assert.Nil(t, result.StructuredContent) + return + } + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + expected := text + if len(structured) > 0 && structured[0] != "" { + expected = structured[0] + } + assert.JSONEq(t, expected, mustMarshalJSON(t, output)) + assert.JSONEq(t, expected, actual) +} + +func TestTypedGitGistWireOutputs(t *testing.T) { + for _, protocol := range typedGitGistProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + var current *gitGistCase + session, schemas := gitGistTypedSession(t, gitGistDeps(t, ¤t, false), protocol) + for _, tc := range gitGistWireCases() { + t.Run(tc.name+"/"+mustMarshalJSON(t, tc.args), func(t *testing.T) { + current = &tc + args := map[string]any{} + if tc.name == "get_repository_tree" { + args["owner"], args["repo"] = "owner", "repo" + } + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: args}) + require.NoError(t, err) + assertGitGistResult(t, result, schemas[tc.name], tc.text, false, tc.structured) + }) + } + }) + } +} + +func TestTypedGitGistWireErrors(t *testing.T) { + cases := []gitGistCase{ + {name: "get_repository_tree", args: map[string]any{"owner": "", "repo": "r"}, text: "missing required parameter: owner"}, + {name: "get_repository_tree", args: map[string]any{"owner": "o", "repo": "r", "recursive": "true"}, text: "parameter recursive is not of type bool, is string"}, + {name: "get_repository_tree", args: map[string]any{"owner": "o", "repo": "r", "tree_sha": 1}, text: "parameter tree_sha is not of type string, is float64"}, + {name: "list_gists", args: map[string]any{"username": 1}, text: "parameter username is not of type string, is float64"}, + {name: "list_gists", args: map[string]any{"since": "bad"}, text: `invalid since timestamp: invalid ISO 8601 timestamp: bad (supported formats: YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DD)`}, + {name: "list_gists", args: map[string]any{"page": "bad"}, text: "parameter page is not a valid number: invalid numeric value: bad"}, + {name: "get_gist", args: map[string]any{}, text: "missing required parameter: gist_id"}, + {name: "create_gist", args: map[string]any{"content": "x"}, text: "missing required parameter: filename"}, + {name: "create_gist", args: map[string]any{"filename": "f", "content": "x", "public": "yes"}, text: "parameter public is not of type bool, is string"}, + {name: "update_gist", args: map[string]any{"gist_id": "g", "description": false, "filename": "f", "content": "c"}, text: "parameter description is not of type string, is bool"}, + } + for _, protocol := range typedGitGistProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + var current *gitGistCase + session, _ := gitGistTypedSession(t, gitGistDeps(t, ¤t, false), protocol) + for _, tc := range cases { + t.Run(tc.name+"/"+tc.text, func(t *testing.T) { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: tc.args}) + require.NoError(t, err) + assertGitGistResult(t, result, nil, tc.text, true) + }) + } + session, _ = gitGistTypedSession(t, gitGistDeps(t, ¤t, true), protocol) + for _, tc := range gitGistWireCases() { + current = &tc + args := map[string]any{"owner": "owner", "repo": "repo"} + if tc.name != "get_repository_tree" { + args = map[string]any{} + } + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: args}) + require.NoError(t, err, tc.name+mustMarshalJSON(t, tc.args)) + require.True(t, result.IsError, tc.name) + assert.Contains(t, getTextResult(t, result).Text, "Forbidden") + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestGitGistOutputSchemasRejectMismatches(t *testing.T) { + for name, tc := range map[string]struct { + schema *jsonschema.Schema + bad string + }{ + "tree": {treeOutputSchema(), `{"sha":"s","truncated":false,"tree":[{"path":"p","type":"blob","mode":"m","sha":"s","url":"u","size":null}],"tree_sha":"t","owner":"o","repo":"r","recursive":false,"count":1}`}, + "gist": {gistOutputSchema(), `{"id":null}`}, + "list": {gistListOutputSchema(), `[{"files":{"a":{"size":"3"}}}]`}, + "create": {gistMutationOutputSchema(), `{"id":"a"}`}, + "api_url": {gistOutputSchema(), `{"git_push_url":"https://gist.github.com/abc.git"}`}, + "node_id": {gistOutputSchema(), `{"owner":{"login":"octocat","node_id":"node"}}`}, + "raw_url": {gistOutputSchema(), `{"files":{"a":{"raw_url":null}}}`}, + "timestamp": {gistOutputSchema(), `{"created_at":42}`}, + "tree_type": {treeOutputSchema(), `{"sha":"s","truncated":false,"tree":[{"path":"p","type":"invalid","mode":"100644","sha":"s"}],"tree_sha":"t","owner":"o","repo":"r","recursive":false,"count":1}`}, + } { + t.Run(name, func(t *testing.T) { + resolved, err := tc.schema.Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(tc.bad), &value)) + require.Error(t, resolved.Validate(value)) + }) + } +} + +func TestGitGistInputMetadata(t *testing.T) { + tool := ListGists(translations.NullTranslationHelper) + schema := tool.Tool.InputSchema.(*jsonschema.Schema) + require.Equal(t, new(1.0), schema.Properties["page"].Minimum) + require.Equal(t, new(1.0), schema.Properties["perPage"].Minimum) + require.Equal(t, new(100.0), schema.Properties["perPage"].Maximum) + assert.Equal(t, "Page number for pagination (min 1)", schema.Properties["page"].Description) + assert.Equal(t, "Results per page for pagination (min 1, max 100)", schema.Properties["perPage"].Description) + for _, schemaFor := range []func() *jsonschema.Schema{ + treeOutputSchema, gistOutputSchema, gistListOutputSchema, gistMutationOutputSchema, + } { + assert.Same(t, schemaFor(), schemaFor()) + } +} + +func TestListGistsLegacyPaginationBounds(t *testing.T) { + for _, tc := range gitGistWireCases() { + if tc.name != "list_gists" || (tc.query != "page=-1&per_page=101" && tc.query != "page=-1&per_page=-2") { + continue + } + t.Run(tc.query, func(t *testing.T) { + current := &tc + deps := gitGistDeps(t, ¤t, false) + tool := ListGists(translations.NullTranslationHelper) + ctx := ContextWithDeps(context.Background(), deps) + request := createMCPRequest(tc.args) + result, err := tool.HandlerFunc(deps)(ctx, &request) + require.NoError(t, err) + assertGitGistResult(t, result, nil, "[]", false) + }) + } +} + +func TestGitGistIFCScopesAndLockdown(t *testing.T) { + tr := translations.NullTranslationHelper + for _, tc := range []struct { + tool inventory.ServerTool + scope inventory.ScopeAccess + }{ + {GetRepositoryTree(tr), scopes.PublicRead(scopes.Repo)}, + {ListGists(tr), scopes.NoScopes()}, + {GetGist(tr), scopes.NoScopes()}, + {CreateGist(tr), scopes.RequireAll(scopes.Gist)}, + {UpdateGist(tr), scopes.RequireAll(scopes.Gist)}, + } { + actual := tc.tool.ScopeAccess + assert.Equal(t, tc.scope.Scopes, actual.Scopes, tc.tool.Tool.Name) + assert.Equal(t, tc.scope.Dynamic, actual.Dynamic) + for _, active := range [][]string{nil, {"gist"}, {"repo"}} { + if tc.scope.Visible == nil { + assert.Nil(t, actual.Visible) + } else { + require.NotNil(t, actual.Visible) + assert.Equal(t, tc.scope.Visible(active), actual.Visible(active)) + } + if tc.scope.Challenge == nil { + assert.Nil(t, actual.Challenge) + } else { + require.NotNil(t, actual.Challenge) + args := map[string]any{"owner": "owner", "repo": "repo"} + assert.Equal(t, tc.scope.Challenge(args, active), actual.Challenge(args, active)) + } + } + } + + for _, protocol := range typedGitGistProtocols { + for _, private := range []bool{false, true} { + for _, enabled := range []bool{false, true} { + for _, lockdown := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/private=%t/ifc=%t/lockdown=%t", protocol, private, enabled, lockdown), func(t *testing.T) { + var current *gitGistCase + deps := gitGistDeps(t, ¤t, false, private) + deps.Flags = stubFeatureFlags(map[string]bool{"lockdown-mode": lockdown}) + if enabled { + deps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + } + session, schemas := gitGistTypedSession(t, deps, protocol) + seen := make(map[string]bool) + for _, tc := range gitGistWireCases() { + if seen[tc.name] { + continue + } + seen[tc.name] = true + current = &tc + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: tc.args}) + require.NoError(t, err) + assertGitGistResult(t, result, schemas[tc.name], tc.text, false, tc.structured) + if !enabled || tc.name == "create_gist" || tc.name == "update_gist" { + assert.Nil(t, result.Meta["ifc"]) + continue + } + expected := ifc.LabelGist() + switch tc.name { + case "get_repository_tree": + expected = ifc.LabelCommitContents(private) + case "list_gists": + expected = ifc.LabelGistList() + } + assert.JSONEq(t, mustMarshalJSON(t, expected), mustMarshalJSON(t, result.Meta["ifc"])) + } + }) + } + } + } + } +} diff --git a/pkg/github/typed_governance_outputs.go b/pkg/github/typed_governance_outputs.go new file mode 100644 index 0000000000..74925ad881 --- /dev/null +++ b/pkg/github/typed_governance_outputs.go @@ -0,0 +1,498 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "reflect" + "strings" + + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type MinimalLabel struct { + Name string `json:"name"` + Color string `json:"color" jsonschema:"Hex RGB without leading #."` + Description string `json:"description"` +} + +type ListLabelsOutput struct { + Labels []MinimalLabel `json:"labels"` + TotalCount int `json:"totalCount" jsonschema:"Total labels in the repository."` +} + +type LabelWriteOutput struct { + Method string `json:"method"` + Message string `json:"message"` +} + +// CustomPropertyOutputValue is the API's string/string-array/null union. +type CustomPropertyOutputValue struct { + raw json.RawMessage +} + +func (v *CustomPropertyOutputValue) UnmarshalJSON(raw []byte) error { + schema, err := customPropertyValueSchema().Properties["value"].Resolve(nil) + if err != nil { + return err + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return err + } + if err := schema.Validate(value); err != nil { + return err + } + v.raw = append(json.RawMessage(nil), raw...) + return nil +} + +func (v CustomPropertyOutputValue) MarshalJSON() ([]byte, error) { + if len(v.raw) == 0 { + return []byte("null"), nil + } + return v.raw, nil +} + +func (v CustomPropertyOutputValue) IsZero() bool { + return len(v.raw) == 0 +} + +type CustomPropertyValueOutput struct { + PropertyName string `json:"property_name"` + Value CustomPropertyOutputValue `json:"value"` +} + +type CustomPropertyDefinitionOutput struct { + PropertyName string `json:"property_name"` + ValueType string `json:"value_type"` + Required *bool `json:"required,omitempty"` + DefaultValue CustomPropertyOutputValue `json:"default_value,omitzero"` + Description *string `json:"description,omitempty"` + AllowedValues []string `json:"allowed_values,omitzero"` + ValuesEditableBy *string `json:"values_editable_by,omitempty"` + RequireExplicitValues *bool `json:"require_explicit_values,omitempty"` +} + +type CustomPropertiesOutput struct { + Method string `json:"method"` + Level string `json:"level"` + Values *[]CustomPropertyValueOutput `json:"values,omitempty"` + Definitions *[]CustomPropertyDefinitionOutput `json:"definitions,omitempty"` + Message string `json:"message,omitempty"` +} + +type RulesetBypassActorOutput struct { + ActorID *int64 `json:"actor_id,omitempty"` + ActorType string `json:"actor_type"` + BypassMode string `json:"bypass_mode"` +} + +type RulesetNameConditionOutput struct { + Include []string `json:"include"` + Exclude []string `json:"exclude"` + Protected *bool `json:"protected,omitempty"` +} + +type RulesetIDConditionOutput struct { + RepositoryIDs []int64 `json:"repository_ids,omitempty"` + OrganizationIDs []int64 `json:"organization_ids,omitempty"` +} + +type RulesetPropertyTargetOutput struct { + Name string `json:"name"` + PropertyValues []string `json:"property_values"` + Source *string `json:"source,omitempty"` +} + +type RulesetPropertyConditionOutput struct { + Include []RulesetPropertyTargetOutput `json:"include"` + Exclude []RulesetPropertyTargetOutput `json:"exclude"` +} + +type RulesetConditionsOutput struct { + RefName *RulesetNameConditionOutput `json:"ref_name,omitempty"` + RepositoryID *RulesetIDConditionOutput `json:"repository_id,omitempty"` + RepositoryName *RulesetNameConditionOutput `json:"repository_name,omitempty"` + RepositoryProperty *RulesetPropertyConditionOutput `json:"repository_property,omitempty"` + OrganizationID *RulesetIDConditionOutput `json:"organization_id,omitempty"` + OrganizationName *RulesetNameConditionOutput `json:"organization_name,omitempty"` + OrganizationProperty *RulesetPropertyConditionOutput `json:"organization_property,omitempty"` +} + +type GovernanceRuleOutput struct { + Type string `json:"type"` + // Parameters are the only passthrough: rule-specific, user-configured JSON. + Parameters json.RawMessage `json:"parameters,omitempty"` + RulesetID *int64 `json:"ruleset_id,omitempty"` + RulesetSource string `json:"ruleset_source,omitempty"` + RulesetSourceType string `json:"ruleset_source_type,omitempty"` +} + +type RulesetOutput struct { + ID *int64 `json:"id,omitempty"` + Name string `json:"name"` + Target string `json:"target,omitempty"` + SourceType string `json:"source_type,omitempty"` + Source string `json:"source"` + Enforcement string `json:"enforcement"` + BypassActors []RulesetBypassActorOutput `json:"bypass_actors,omitzero"` + CurrentUserCanBypass string `json:"current_user_can_bypass,omitempty"` + Conditions *RulesetConditionsOutput `json:"conditions,omitempty"` + Rules []GovernanceRuleOutput `json:"rules,omitzero"` + CreatedAt string `json:"created_at,omitempty" jsonschema:"RFC3339 creation time."` + UpdatedAt string `json:"updated_at,omitempty" jsonschema:"RFC3339 last update time."` +} + +type RuleEvaluationOutput struct { + RuleSource *RuleSourceOutput `json:"rule_source,omitempty"` + Enforcement string `json:"enforcement,omitempty"` + Result string `json:"result"` + RuleType string `json:"rule_type"` + Details string `json:"details,omitempty"` +} + +type RuleSourceOutput struct { + Type string `json:"type"` + ID *int64 `json:"id"` + Name string `json:"name,omitempty"` +} + +type RuleSuiteOutput struct { + ID int64 `json:"id"` + ActorID *int64 `json:"actor_id,omitempty"` + ActorName string `json:"actor_name,omitempty"` + BeforeSHA string `json:"before_sha,omitempty"` + AfterSHA string `json:"after_sha,omitempty"` + Ref string `json:"ref,omitempty"` + PushedAt string `json:"pushed_at,omitempty" jsonschema:"RFC3339 push time."` + Result string `json:"result"` + EvaluationResult *string `json:"evaluation_result,omitempty" jsonschema:"Result of rules evaluated without enforcement, distinct from the enforced result."` + RepositoryID *int64 `json:"repository_id,omitempty"` + RepositoryName string `json:"repository_name,omitempty"` + Evaluations []RuleEvaluationOutput `json:"rule_evaluations,omitempty"` +} + +type RulesetReadOutput struct { + Method string `json:"method"` + Level string `json:"level"` + Ruleset *RulesetOutput `json:"ruleset,omitempty"` + Rulesets *[]RulesetOutput `json:"rulesets,omitempty"` + TotalCount *int `json:"total_count,omitempty"` + Rules *[]GovernanceRuleOutput `json:"rules,omitempty"` + RuleSuites *[]RuleSuiteOutput `json:"rule_suites,omitempty"` + RuleSuite *RuleSuiteOutput `json:"rule_suite,omitempty"` +} + +func typedGovernanceSchema[T any]() *jsonschema.Schema { + definitions := map[string]reflect.Type{ + "ruleset": reflect.TypeFor[RulesetOutput](), + "rule": reflect.TypeFor[GovernanceRuleOutput](), + "ruleSuite": reflect.TypeFor[RuleSuiteOutput](), + "nameCondition": reflect.TypeFor[RulesetNameConditionOutput](), + "idCondition": reflect.TypeFor[RulesetIDConditionOutput](), + "propertyCondition": reflect.TypeFor[RulesetPropertyConditionOutput](), + } + options := &jsonschema.ForOptions{ + TypeSchemas: map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[CustomPropertyOutputValue](): customPropertyValueSchema().Properties["value"], + reflect.TypeFor[json.RawMessage](): { + Type: "object", + Description: "User-configured parameters for this rule type.", + }, + }, + } + for name, typ := range definitions { + if typ != reflect.TypeFor[T]() { + options.TypeSchemas[typ] = &jsonschema.Schema{Type: "object", Ref: "#/$defs/" + name} + } + } + schema, err := jsonschema.For[T](options) + if err != nil { + panic(err) + } + governanceStateSchema(schema, reflect.TypeFor[T]()) + schema.Defs = make(map[string]*jsonschema.Schema) + for { + added := false + encoded, err := json.Marshal(schema) + if err != nil { + panic(err) + } + for name, typ := range definitions { + if schema.Defs[name] != nil || !strings.Contains(string(encoded), `#/$defs/`+name) { + continue + } + definitionOptions := &jsonschema.ForOptions{TypeSchemas: maps.Clone(options.TypeSchemas)} + delete(definitionOptions.TypeSchemas, typ) + definition, err := jsonschema.ForType(typ, definitionOptions) + if err != nil { + panic(err) + } + governanceStateSchema(definition, typ) + schema.Defs[name] = definition + added = true + } + if !added { + break + } + } + return forbidOmittedNulls(schema) +} + +func governanceStateSchema(schema *jsonschema.Schema, typ reflect.Type) { + switch typ { + case reflect.TypeFor[RulesetOutput](): + schema.Properties["enforcement"].Enum = []any{"", "disabled", "active", "evaluate"} + schema.Properties["target"].Enum = []any{"branch", "tag", "push", "repository"} + schema.Properties["source_type"].Enum = []any{"Repository", "Organization", "Enterprise"} + for _, name := range []string{"created_at", "updated_at"} { + schema.Properties[name].Format = "date-time" + } + case reflect.TypeFor[RuleSuiteOutput](): + schema.Properties["result"].Enum = []any{"pass", "fail", "bypass"} + schema.Properties["pushed_at"].Format = "date-time" + } +} + +func labelOutputSchema() *jsonschema.Schema { return typedGovernanceSchema[MinimalLabel]() } +func listLabelsOutputSchema() *jsonschema.Schema { return typedGovernanceSchema[ListLabelsOutput]() } +func labelWriteOutputSchema() *jsonschema.Schema { + schema := typedGovernanceSchema[LabelWriteOutput]() + schema.Properties["method"].Enum = []any{"create", "update", "delete"} + return schema +} +func customPropertiesReadOutputSchema() *jsonschema.Schema { + schema := typedGovernanceSchema[CustomPropertiesOutput]() + schema.Properties["method"].Enum = []any{"read"} + schema.Properties["level"].Enum = []any{"repository", "organization", "enterprise"} + delete(schema.Properties, "message") + customPropertyOutputStates(schema) + return schema +} +func customPropertiesWriteOutputSchema() *jsonschema.Schema { + schema := typedGovernanceSchema[CustomPropertiesOutput]() + schema.Properties["method"].Enum = []any{"write"} + schema.Properties["level"].Enum = []any{"repository", "organization", "enterprise"} + delete(schema.Properties, "values") + customPropertyOutputStates(schema) + return schema +} + +func customPropertyOutputStates(schema *jsonschema.Schema) { + definition := schema.Properties["definitions"].Items + definition.Properties["value_type"].Enum = []any{"", "string", "single_select", "multi_select", "true_false", "url"} + definition.Properties["values_editable_by"].Enum = []any{"org_actors", "org_and_repo_actors"} +} +func rulesetReadOutputSchema() *jsonschema.Schema { + schema := typedGovernanceSchema[RulesetReadOutput]() + schema.Properties["method"].Enum = []any{"get", "list", "get_rules_for_branch", "list_rule_suites", "get_rule_suite"} + schema.Properties["level"].Enum = []any{"repository", "organization", "enterprise"} + return schema +} +func createdRulesetOutputSchema() *jsonschema.Schema { return typedGovernanceSchema[RulesetOutput]() } + +func decodeGovernanceJSON[T any](raw []byte, _ map[string]json.RawMessage) (T, error) { + var output T + err := json.Unmarshal(raw, &output) + return output, err +} + +func governanceArgument(args map[string]json.RawMessage, name string) string { + var value string + _ = json.Unmarshal(args[name], &value) + return value +} + +func decodeCustomProperties(method string) func([]byte, map[string]json.RawMessage) (CustomPropertiesOutput, error) { + return func(raw []byte, args map[string]json.RawMessage) (CustomPropertiesOutput, error) { + out := CustomPropertiesOutput{Method: method, Level: governanceArgument(args, "level")} + if out.Level == "repository" { + if method == "write" { + out.Message = string(raw) + return out, nil + } + values := []CustomPropertyValueOutput{} + err := json.Unmarshal(raw, &values) + if values == nil { + values = []CustomPropertyValueOutput{} + } + out.Values = new(values) + return out, err + } + definitions := []CustomPropertyDefinitionOutput{} + err := json.Unmarshal(raw, &definitions) + if definitions == nil { + definitions = []CustomPropertyDefinitionOutput{} + } + out.Definitions = new(definitions) + return out, err + } +} + +func decodeRulesetRead(raw []byte, args map[string]json.RawMessage) (RulesetReadOutput, error) { + out := RulesetReadOutput{ + Method: strings.ToLower(governanceArgument(args, "method")), + Level: governanceArgument(args, "level"), + } + var err error + switch out.Method { + case "get": + err = json.Unmarshal(raw, &out.Ruleset) + case "list": + if out.Level == "enterprise" { + err = json.Unmarshal(raw, &out) + if out.Rulesets == nil || *out.Rulesets == nil { + out.Rulesets = new([]RulesetOutput{}) + } + } else { + rulesets := []RulesetOutput{} + err = json.Unmarshal(raw, &rulesets) + if rulesets == nil { + rulesets = []RulesetOutput{} + } + out.Rulesets = new(rulesets) + } + case "get_rules_for_branch": + // go-github's legacy BranchRules text groups rules by Go field name. + var groups map[string][]GovernanceRuleOutput + err = json.Unmarshal(raw, &groups) + if err == nil { + rules := []GovernanceRuleOutput{} + for _, name := range governanceBranchRuleNames { + for _, rule := range groups[name.field] { + rule.Type = name.kind + rules = append(rules, rule) + } + } + out.Rules = new(rules) + } + case "list_rule_suites": + suites := []RuleSuiteOutput{} + err = json.Unmarshal(raw, &suites) + if suites == nil { + suites = []RuleSuiteOutput{} + } + out.RuleSuites = new(suites) + case "get_rule_suite": + err = json.Unmarshal(raw, &out.RuleSuite) + default: + err = fmt.Errorf("unexpected successful ruleset method %q", out.Method) + } + return out, err +} + +var governanceBranchRuleNames = []struct{ field, kind string }{ + {"Creation", "creation"}, {"Update", "update"}, {"Deletion", "deletion"}, + {"RequiredLinearHistory", "required_linear_history"}, {"MergeQueue", "merge_queue"}, + {"RequiredDeployments", "required_deployments"}, {"RequiredSignatures", "required_signatures"}, + {"PullRequest", "pull_request"}, {"RequiredStatusChecks", "required_status_checks"}, + {"NonFastForward", "non_fast_forward"}, {"CommitMessagePattern", "commit_message_pattern"}, + {"CommitAuthorEmailPattern", "commit_author_email_pattern"}, {"CommitterEmailPattern", "committer_email_pattern"}, + {"BranchNamePattern", "branch_name_pattern"}, {"TagNamePattern", "tag_name_pattern"}, + {"Workflows", "workflows"}, {"CodeScanning", "code_scanning"}, {"CopilotCodeReview", "copilot_code_review"}, + {"FileExtensionRestriction", "file_extension_restriction"}, {"FilePathRestriction", "file_path_restriction"}, + {"MaxFilePathLength", "max_file_path_length"}, {"MaxFileSize", "max_file_size"}, +} + +// governanceLevelRoutingFields maps each "level" value accepted by the +// level-routed governance tools (custom_properties_read/write, +// repository_ruleset_read, create_repository_ruleset) to the input field(s) +// the legacy handler reads only at that level. Fields unused at the call's +// level are legacy no-ops: the handler never looks at them, so a malformed +// (wrong-typed) unused field must not fail validation before level routing +// even runs. +var governanceLevelRoutingFields = map[string][]string{ + "repository": {"owner", "repo"}, + "organization": {"org"}, + "enterprise": {"enterprise"}, +} + +// normalizeGovernanceLevelRouting strips level-routing fields that are unused +// for the call's "level" before SDK schema validation runs, so an unused +// field's type (or presence) can never produce a validation error the legacy +// handler itself would never raise. It never touches used fields, and it +// leaves arguments untouched whenever "level" is missing, non-string, or not +// a recognized value, so the legacy handler's own "unknown level" error still +// fires exactly as before. +func normalizeGovernanceLevelRouting(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]json.RawMessage + if err := json.Unmarshal(raw, &args); err != nil { + return raw, nil + } + levelRaw, ok := args["level"] + if !ok { + return raw, nil + } + var level string + if err := json.Unmarshal(levelRaw, &level); err != nil { + return raw, nil + } + used, known := governanceLevelRoutingFields[level] + if !known { + return raw, nil + } + keep := make(map[string]bool, len(used)) + for _, field := range used { + keep[field] = true + } + changed := false + for _, fields := range governanceLevelRoutingFields { + for _, field := range fields { + if keep[field] { + continue + } + if _, present := args[field]; present { + delete(args, field) + changed = true + } + } + } + if !changed { + return raw, nil + } + return json.Marshal(args) +} + +func typedGovernanceTool[Out any]( + legacy inventory.ServerTool, + outputSchema *jsonschema.Schema, + decode func([]byte, map[string]json.RawMessage) (Out, error), + normalizers ...inventory.InputNormalizer, +) inventory.ServerTool { + tool := legacy.Tool + tool.OutputSchema = outputSchema + // Legacy handlers validate only the fields consumed by the selected route. + // Keep discovery unchanged while preserving their accepted argument forms. + typed := NewToolWithSchemaOptions[map[string]json.RawMessage, *Out]( + legacy.Toolset, tool, legacy.ScopeAccess, + inventory.TypedSchemaOptions{ValidationInputSchema: &jsonschema.Schema{Type: "object"}}, + func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]json.RawMessage) (*mcp.CallToolResult, *Out, error) { + result, err := legacy.Handler(deps)(ctx, req) + if err != nil || result == nil || result.IsError { + return result, nil, err + } + if len(result.Content) != 1 { + return nil, nil, fmt.Errorf("expected one legacy %s text result", tool.Name) + } + content, ok := result.Content[0].(*mcp.TextContent) + if !ok { + return nil, nil, fmt.Errorf("expected legacy %s text content", tool.Name) + } + output, err := decode([]byte(content.Text), args) + if err != nil { + return nil, nil, fmt.Errorf("decode %s output: %w", tool.Name, err) + } + return result, new(output), nil + }, + normalizers..., + ) + typed.FeatureRule = legacy.FeatureRule + typed.Enabled = legacy.Enabled + typed.MinimumProtocolVersion = legacy.MinimumProtocolVersion + typed.RequiredElicitationMode = legacy.RequiredElicitationMode + return typed +} diff --git a/pkg/github/typed_governance_outputs_test.go b/pkg/github/typed_governance_outputs_test.go new file mode 100644 index 0000000000..b274d0e68d --- /dev/null +++ b/pkg/github/typed_governance_outputs_test.go @@ -0,0 +1,830 @@ +package github + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type governanceWireCase struct { + tool inventory.ServerTool + args map[string]any + status int + body string + text string + output string + contains string + route func(*testing.T, *http.Request) string +} + +func governanceLegacyTool(name string) inventory.ServerTool { + tr := translations.NullTranslationHelper + switch name { + case "get_label": + return getLabelLegacy(tr) + case "list_label": + return listLabelsLegacy(tr) + case "label_write": + return labelWriteLegacy(tr) + case "custom_properties_read": + return customPropertiesReadLegacy(tr) + case "custom_properties_write": + return customPropertiesWriteLegacy(tr) + case "repository_ruleset_read": + return repositoryRulesetReadLegacy(tr) + case "create_repository_ruleset": + return createRepositoryRulesetLegacy(tr) + default: + panic("unexpected governance tool: " + name) + } +} + +func assertGovernanceWireText(t *testing.T, modern bool, legacyText string, result *mcp.CallToolResult) { + t.Helper() + actual := getTextResult(t, result).Text + if modern && !result.IsError { + require.NotNil(t, result.StructuredContent) + assert.JSONEq(t, mustMarshalJSON(t, result.StructuredContent), actual) + return + } + assert.Equal(t, legacyText, actual) +} + +func assertGovernanceProjection(t *testing.T, tc governanceWireCase, output any) { + t.Helper() + object, ok := output.(map[string]any) + require.True(t, ok, "modern output must have an object root") + if tc.output != "" { + assert.JSONEq(t, tc.output, mustMarshalJSON(t, output)) + } + switch tc.tool.Tool.Name { + case "get_label": + assert.JSONEq(t, `{"name":"bug","color":"ff0000","description":"desc"}`, mustMarshalJSON(t, output)) + case "list_label": + assert.JSONEq(t, `{"labels":[{"name":"bug","color":"ff0000","description":"desc"}],"totalCount":1}`, mustMarshalJSON(t, output)) + case "label_write": + assert.Equal(t, tc.args["method"], object["method"]) + assert.Equal(t, tc.text, object["message"]) + case "custom_properties_read", "custom_properties_write": + assert.Equal(t, tc.args["level"], object["level"]) + if tc.tool.Tool.Name == "custom_properties_write" { + assert.Equal(t, "write", object["method"]) + } else { + assert.Equal(t, "read", object["method"]) + } + switch { + case tc.args["level"] == "repository" && tc.tool.Tool.Name == "custom_properties_write": + assert.Equal(t, tc.text, object["message"]) + case tc.args["level"] == "repository": + assert.JSONEq(t, tc.text, mustMarshalJSON(t, object["values"])) + default: + var expected []map[string]any + require.NoError(t, json.Unmarshal([]byte(tc.text), &expected)) + for _, definition := range expected { + delete(definition, "custom_field") + } + assert.JSONEq(t, mustMarshalJSON(t, expected), mustMarshalJSON(t, object["definitions"])) + } + case "repository_ruleset_read": + assert.Equal(t, tc.args["level"], object["level"]) + assert.Equal(t, tc.args["method"], object["method"]) + switch tc.args["method"] { + case "get": + assert.Equal(t, float64(7), object["ruleset"].(map[string]any)["id"]) + case "list": + require.Len(t, object["rulesets"], 1) + if tc.args["level"] == "enterprise" { + assert.Equal(t, float64(1), object["total_count"]) + } + case "get_rules_for_branch": + assert.JSONEq(t, `[{"type":"creation","ruleset_id":0}]`, mustMarshalJSON(t, object["rules"])) + case "get_rule_suite": + assert.JSONEq(t, `{"id":11,"result":"pass"}`, mustMarshalJSON(t, object["rule_suite"])) + } + case "create_repository_ruleset": + assert.JSONEq(t, tc.text, mustMarshalJSON(t, output)) + } + for _, excluded := range []string{"url", "html_url", "_links", "node_id", "custom", "custom_field"} { + assert.NotContains(t, mustMarshalJSON(t, output), `"`+excluded+`":`) + } +} + +func governanceTypedSession(t *testing.T, tool inventory.ServerTool, deps ToolDependencies, protocol string) (*mcp.ClientSession, *jsonschema.Resolved) { + t.Helper() + server := mcp.NewServer(&mcp.Implementation{Name: "governance", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + tool.RegisterFunc(server, deps) + if protocol == "" || protocol == "unknown" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch request := req.(type) { + case *mcp.ListToolsRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + case *mcp.CallToolRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + } + return next(ctx, method, req) + } + }) + } + version := protocol + if version == "" || version == "unknown" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, list.Tools[0].OutputSchema) + return session, nil + } + require.NotNil(t, list.Tools[0].OutputSchema) + require.NoError(t, toolsnaps.Test(tool.Tool.Name, tool.Tool)) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, list.Tools[0].OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + return session, resolved +} + +func governanceWireDeps(t *testing.T, tc governanceWireCase) BaseDeps { + t.Helper() + var graphqlCalls int + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if tc.route != nil { + body := tc.route(t, r) + if body == "" { + w.WriteHeader(http.StatusNoContent) + return + } + _, _ = io.WriteString(w, body) + return + } + if r.URL.Path == "/repos/o/r" { + _, _ = io.WriteString(w, `{"private":false}`) + return + } + if r.URL.Path == "/graphql" { + graphqlCalls++ + var body string + switch tc.tool.Tool.Name { + case "get_label": + body = `{"data":{"repository":{"label":{"id":"L1","name":"bug","color":"ff0000","description":"desc"}}}}` + case "list_label": + body = `{"data":{"repository":{"labels":{"nodes":[{"id":"L1","name":"bug","color":"ff0000","description":"desc"}],"totalCount":1}}}}` + case "label_write": + switch tc.args["method"] { + case "create": + if graphqlCalls == 1 { + body = `{"data":{"repository":{"id":"R1"}}}` + } else { + body = `{"data":{"createLabel":{"label":{"id":"L2","name":"feature"}}}}` + } + case "update": + if graphqlCalls == 1 { + body = `{"data":{"repository":{"label":{"id":"L1","name":"bug"}}}}` + } else { + body = `{"data":{"updateLabel":{"label":{"id":"L1","name":"defect"}}}}` + } + case "delete": + if graphqlCalls == 1 { + body = `{"data":{"repository":{"label":{"id":"L1","name":"bug"}}}}` + } else { + body = `{"data":{"deleteLabel":{"clientMutationId":"mutation-1"}}}` + } + } + } + _, _ = io.WriteString(w, body) + return + } + w.WriteHeader(tc.status) + _, _ = io.WriteString(w, tc.body) + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client)} + if tc.tool.Tool.Name == "get_label" || tc.tool.Tool.Name == "list_label" || tc.tool.Tool.Name == "label_write" { + deps.GQLClient = githubv4.NewEnterpriseClient("https://api.github.com/graphql", client) + } + return deps +} + +func governanceWireCases(t *testing.T) []governanceWireCase { + t.Helper() + rest := func(method, path, body string) func(*testing.T, *http.Request) string { + return func(t *testing.T, request *http.Request) string { + t.Helper() + assert.Equal(t, method, request.Method) + assert.Equal(t, path, request.URL.Path) + if request.Body != nil { + _, _ = io.Copy(io.Discard, request.Body) + } + return body + } + } + tr := translations.NullTranslationHelper + return []governanceWireCase{ + {tool: GetLabel(tr), args: map[string]any{"owner": "o", "repo": "r", "name": "bug"}, text: `{"id":"L1","name":"bug","color":"ff0000","description":"desc"}`}, + {tool: ListLabels(tr), args: map[string]any{"owner": "o", "repo": "r"}, text: `{"labels":[{"id":"L1","name":"bug","color":"ff0000","description":"desc"}],"totalCount":1}`}, + {tool: LabelWrite(tr), args: map[string]any{"method": "create", "owner": "o", "repo": "r", "name": "feature", "color": "abcdef"}, text: "label 'feature' created successfully"}, + {tool: LabelWrite(tr), args: map[string]any{"method": "update", "owner": "o", "repo": "r", "name": "bug", "new_name": "defect"}, text: "label 'defect' updated successfully"}, + {tool: LabelWrite(tr), args: map[string]any{"method": "delete", "owner": "o", "repo": "r", "name": "bug"}, text: "label 'bug' deleted successfully"}, + {tool: CustomPropertiesRead(tr), args: map[string]any{"level": "repository", "owner": "o", "repo": "r"}, status: http.StatusOK, body: `[{"property_name":"environment","value":"prod"}]`, text: `[{"property_name":"environment","value":"prod"}]`, route: rest(http.MethodGet, "/repos/o/r/properties/values", `[{"property_name":"environment","value":"prod"}]`)}, + {tool: CustomPropertiesRead(tr), args: map[string]any{"level": "organization", "org": "o"}, status: http.StatusOK, body: `[{"property_name":"environment","value_type":"string","custom_field":true}]`, text: `[{"custom_field":true,"property_name":"environment","value_type":"string"}]`, route: rest(http.MethodGet, "/orgs/o/properties/schema", `[{"property_name":"environment","value_type":"string","custom_field":true}]`)}, + {tool: CustomPropertiesRead(tr), args: map[string]any{"level": "enterprise", "enterprise": "e"}, status: http.StatusOK, body: `[{"property_name":"classification","value_type":"single_select"}]`, text: `[{"property_name":"classification","value_type":"single_select"}]`, route: rest(http.MethodGet, "/enterprises/e/properties/schema", `[{"property_name":"classification","value_type":"single_select"}]`)}, + {tool: CustomPropertiesWrite(tr), args: map[string]any{"level": "repository", "owner": "o", "repo": "r", "properties": []any{map[string]any{"property_name": "environment", "value": "prod"}}}, text: "Repository custom property values updated successfully", route: rest(http.MethodPatch, "/repos/o/r/properties/values", "")}, + {tool: CustomPropertiesWrite(tr), args: map[string]any{"level": "organization", "org": "o", "properties": []any{map[string]any{"property_name": "classification", "value_type": "string"}}}, text: `[{"property_name":"classification","value_type":"string"}]`, route: func(t *testing.T, request *http.Request) string { + t.Helper() + assert.Equal(t, "/orgs/o/properties/schema", request.URL.Path) + switch request.Method { + case http.MethodGet: + return `[{"property_name":"classification","value_type":"string"}]` + case http.MethodPatch: + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(request.Body).Decode(&body)) + assert.Contains(t, string(body["properties"]), `"classification"`) + return `[{"property_name":"classification","value_type":"string"}]` + default: + t.Fatalf("unexpected request method %s", request.Method) + return "" + } + }}, + {tool: CustomPropertiesWrite(tr), args: map[string]any{"level": "enterprise", "enterprise": "e", "properties": []any{map[string]any{"property_name": "classification", "value_type": "single_select"}}}, text: `[{"property_name":"classification","value_type":"single_select"}]`, route: func(t *testing.T, request *http.Request) string { + t.Helper() + assert.Equal(t, "/enterprises/e/properties/schema", request.URL.Path) + switch request.Method { + case http.MethodGet: + return `[]` + case http.MethodPatch: + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(request.Body).Decode(&body)) + assert.Contains(t, string(body["properties"]), `"classification"`) + return `[{"property_name":"classification","value_type":"single_select"}]` + default: + t.Fatalf("unexpected request method %s", request.Method) + return "" + } + }}, + {tool: CustomPropertiesWrite(tr), args: map[string]any{"level": "organization", "org": "o", "properties": []any{}}, text: `[]`, route: func(t *testing.T, request *http.Request) string { + t.Helper() + assert.Equal(t, "/orgs/o/properties/schema", request.URL.Path) + switch request.Method { + case http.MethodGet: + return `[]` + case http.MethodPatch: + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(request.Body).Decode(&body)) + assert.JSONEq(t, `[]`, string(body["properties"])) + return `[]` + default: + t.Fatalf("unexpected request method %s", request.Method) + return "" + } + }}, + {tool: RepositoryRulesetRead(tr), args: map[string]any{"level": "repository", "method": "get", "owner": "o", "repo": "r", "ruleset_id": 7}, status: http.StatusOK, body: `{"id":7,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[]}`, text: `{"id":7,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[]}`, route: rest(http.MethodGet, "/repos/o/r/rulesets/7", `{"id":7,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[]}`)}, + {tool: RepositoryRulesetRead(tr), args: map[string]any{"level": "organization", "method": "get", "org": "o", "ruleset_id": 7}, status: http.StatusOK, body: `{"id":7,"name":"org-main","target":"branch","source":"org:o","enforcement":"active","rules":[]}`, text: `{"id":7,"name":"org-main","target":"branch","source":"org:o","enforcement":"active","rules":[]}`, output: `{"method":"get","level":"organization","ruleset":{"id":7,"name":"org-main","target":"branch","source":"org:o","enforcement":"active","rules":[]}}`, route: rest(http.MethodGet, "/orgs/o/rulesets/7", `{"id":7,"name":"org-main","target":"branch","source":"org:o","enforcement":"active","rules":[]}`)}, + {tool: RepositoryRulesetRead(tr), args: map[string]any{"level": "enterprise", "method": "get", "enterprise": "e", "ruleset_id": 7}, status: http.StatusOK, body: `{"id":7,"name":"enterprise-main","target":"branch","source":"enterprise:e","enforcement":"active","rules":[]}`, text: `{"id":7,"name":"enterprise-main","target":"branch","source":"enterprise:e","enforcement":"active","rules":[]}`, output: `{"method":"get","level":"enterprise","ruleset":{"id":7,"name":"enterprise-main","target":"branch","source":"enterprise:e","enforcement":"active","rules":[]}}`, route: rest(http.MethodGet, "/enterprises/e/rulesets/7", `{"id":7,"name":"enterprise-main","target":"branch","source":"enterprise:e","enforcement":"active","rules":[]}`)}, + {tool: RepositoryRulesetRead(tr), args: map[string]any{"level": "repository", "method": "list", "owner": "o", "repo": "r"}, status: http.StatusOK, body: `[{"id":7,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[]}]`, text: `[{"id":7,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[]}]`, route: rest(http.MethodGet, "/repos/o/r/rulesets", `[{"id":7,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[]}]`)}, + {tool: RepositoryRulesetRead(tr), args: map[string]any{"level": "repository", "method": "get_rules_for_branch", "owner": "o", "repo": "r", "branch": "main"}, status: http.StatusOK, body: `[{"type":"creation"}]`, contains: "Creation", route: rest(http.MethodGet, "/repos/o/r/rules/branches/main", `[{"type":"creation"}]`)}, + {tool: RepositoryRulesetRead(tr), args: map[string]any{"level": "organization", "method": "get_rule_suite", "org": "o", "rule_suite_id": 11}, status: http.StatusOK, body: `{"id":11,"result":"pass","custom":{"retained":true}}`, text: `{"custom":{"retained":true},"id":11,"result":"pass"}`, route: rest(http.MethodGet, "/orgs/o/rulesets/rule-suites/11", `{"id":11,"result":"pass","custom":{"retained":true}}`)}, + {tool: RepositoryRulesetRead(tr), args: map[string]any{"level": "organization", "method": "list", "org": "o"}, status: http.StatusOK, body: `[{"id":8,"name":"org rules"}]`, text: `[{"id":8,"name":"org rules","enforcement":"","source":""}]`, route: rest(http.MethodGet, "/orgs/o/rulesets", `[{"id":8,"name":"org rules"}]`)}, + {tool: RepositoryRulesetRead(tr), args: map[string]any{"level": "enterprise", "method": "list", "enterprise": "e"}, status: http.StatusOK, body: `{"total_count":1,"rulesets":[{"id":9,"name":"enterprise rules"}]}`, text: `{"rulesets":[{"id":9,"name":"enterprise rules","enforcement":"","source":""}],"total_count":1}`, route: rest(http.MethodGet, "/enterprises/e/rulesets", `{"total_count":1,"rulesets":[{"id":9,"name":"enterprise rules"}]}`)}, + {tool: CreateRepositoryRuleset(tr), args: map[string]any{"level": "repository", "owner": "o", "repo": "r", "name": "main", "enforcement": "active", "rules": []any{map[string]any{"type": "creation"}}}, status: http.StatusCreated, body: `{"id":8,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[{"type":"creation"}]}`, text: `{"id":8,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[{"type":"creation"}]}`, route: rest(http.MethodPost, "/repos/o/r/rulesets", `{"id":8,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[{"type":"creation"}]}`)}, + {tool: CreateRepositoryRuleset(tr), args: map[string]any{"level": "organization", "org": "o", "name": "org-main", "enforcement": "active", "rules": []any{map[string]any{"type": "creation"}}}, status: http.StatusCreated, body: `{"id":8,"name":"org-main","target":"branch","source":"org:o","enforcement":"active","rules":[{"type":"creation"}]}`, text: `{"id":8,"name":"org-main","target":"branch","source":"org:o","enforcement":"active","rules":[{"type":"creation"}]}`, output: `{"id":8,"name":"org-main","target":"branch","source":"org:o","enforcement":"active","rules":[{"type":"creation"}]}`, route: rest(http.MethodPost, "/orgs/o/rulesets", `{"id":8,"name":"org-main","target":"branch","source":"org:o","enforcement":"active","rules":[{"type":"creation"}]}`)}, + {tool: CreateRepositoryRuleset(tr), args: map[string]any{"level": "enterprise", "enterprise": "e", "name": "enterprise-main", "enforcement": "active", "rules": []any{map[string]any{"type": "creation"}}}, status: http.StatusCreated, body: `{"id":8,"name":"enterprise-main","target":"branch","source":"enterprise:e","enforcement":"active","rules":[{"type":"creation"}]}`, text: `{"id":8,"name":"enterprise-main","target":"branch","source":"enterprise:e","enforcement":"active","rules":[{"type":"creation"}]}`, output: `{"id":8,"name":"enterprise-main","target":"branch","source":"enterprise:e","enforcement":"active","rules":[{"type":"creation"}]}`, route: rest(http.MethodPost, "/enterprises/e/rulesets", `{"id":8,"name":"enterprise-main","target":"branch","source":"enterprise:e","enforcement":"active","rules":[{"type":"creation"}]}`)}, + } +} + +func TestTypedGovernanceWireOutputs(t *testing.T) { + protocols := []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} + for _, protocol := range protocols { + t.Run("protocol="+protocol, func(t *testing.T) { + for _, tc := range governanceWireCases(t) { + t.Run(tc.tool.Tool.Name+"/"+tc.text, func(t *testing.T) { + session, schema := governanceTypedSession(t, tc.tool, governanceWireDeps(t, tc), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + legacy := governanceLegacyTool(tc.tool.Tool.Name) + legacyDeps := governanceWireDeps(t, tc) + legacyResult, err := legacy.Handler(legacyDeps)(ContextWithDeps(context.Background(), legacyDeps), new(createMCPRequest(tc.args))) + require.NoError(t, err) + legacyText := getTextResult(t, legacyResult).Text + assertGovernanceWireText(t, schema != nil, legacyText, result) + switch { + case tc.contains != "": + assert.Contains(t, legacyText, tc.contains) + case json.Valid([]byte(tc.text)): + assert.JSONEq(t, tc.text, legacyText) + default: + assert.Equal(t, tc.text, legacyText) + } + if schema == nil { + assert.Nil(t, result.StructuredContent) + return + } + require.NotNil(t, result.StructuredContent) + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + assertGovernanceProjection(t, tc, output) + }) + } + }) + } +} + +func TestRuleSuiteOutputFidelity(t *testing.T) { + for _, sourceID := range []string{"null", "0", "7"} { + for _, method := range []string{"get_rule_suite", "list_rule_suites"} { + for _, level := range []string{"repository", "organization"} { + t.Run(level+"/"+method+"/"+sourceID, func(t *testing.T) { + suite := `{"id":11,"result":"pass","evaluation_result":"fail","rule_evaluations":[{"rule_source":{"type":"protected_branch","id":` + sourceID + `},"result":"pass","rule_type":"pull_request"}]}` + body, field := suite, "rule_suite" + if method == "list_rule_suites" { + body, field = "["+suite+"]", "rule_suites" + } + tc := governanceWireCase{ + tool: RepositoryRulesetRead(translations.NullTranslationHelper), + args: map[string]any{"method": method, "level": level, "owner": "o", "repo": "r", "org": "o", "rule_suite_id": 11}, + route: func(t *testing.T, r *http.Request) string { + t.Helper() + assert.Equal(t, http.MethodGet, r.Method) + path := "/repos/o/r/rulesets/rule-suites" + if level == "organization" { + path = "/orgs/o/rulesets/rule-suites" + } + if method == "get_rule_suite" { + path += "/11" + } + assert.Equal(t, path, r.URL.Path) + return body + }, + } + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + session, schema := governanceTypedSession(t, tc.tool, governanceWireDeps(t, tc), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.False(t, result.IsError) + var legacy any + require.NoError(t, json.Unmarshal([]byte(body), &legacy)) + assertGovernanceWireText(t, schema != nil, mustMarshalJSON(t, legacy), result) + if schema == nil { + assert.Nil(t, result.StructuredContent) + continue + } + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + assert.JSONEq(t, `{"method":"`+method+`","level":"`+level+`","`+field+`":`+body+`}`, mustMarshalJSON(t, output)) + } + }) + } + } + } +} + +func TestTypedGovernanceLegacyInputErrors(t *testing.T) { + cases := []struct { + tool inventory.ServerTool + args map[string]any + text string + }{ + {tool: GetLabel(translations.NullTranslationHelper), args: map[string]any{"repo": "r", "name": "bug"}, text: "missing required parameter: owner"}, + {tool: GetLabel(translations.NullTranslationHelper), args: map[string]any{"owner": false, "repo": "r", "name": "bug"}, text: "parameter owner is not of type string"}, + {tool: ListLabels(translations.NullTranslationHelper), args: map[string]any{"owner": "o"}, text: "missing required parameter: repo"}, + {tool: LabelWrite(translations.NullTranslationHelper), args: map[string]any{"method": false}, text: "parameter method is not of type string"}, + {tool: LabelWrite(translations.NullTranslationHelper), args: map[string]any{"method": false, "owner": 42}, text: "parameter method is not of type string"}, + {tool: RepositoryRulesetRead(translations.NullTranslationHelper), args: map[string]any{"level": "repository"}, text: "missing required parameter: method"}, + {tool: CustomPropertiesRead(translations.NullTranslationHelper), args: map[string]any{"level": false}, text: "parameter level is not of type string"}, + {tool: CreateRepositoryRuleset(translations.NullTranslationHelper), args: map[string]any{"level": "repository"}, text: "missing required parameter: name"}, + {tool: CreateRepositoryRuleset(translations.NullTranslationHelper), args: map[string]any{"level": "repository", "name": false, "owner": false}, text: "parameter name is not of type string"}, + } + for _, tc := range cases { + t.Run("legacy/"+tc.tool.Tool.Name+"/"+tc.text, func(t *testing.T) { + deps := BaseDeps{} + legacy := governanceLegacyTool(tc.tool.Tool.Name) + request := createMCPRequest(tc.args) + result, err := legacy.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, tc.text, getTextResult(t, result).Text) + }) + } + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} { + t.Run("protocol="+protocol, func(t *testing.T) { + for _, tc := range cases { + t.Run(tc.tool.Tool.Name+"/"+tc.text, func(t *testing.T) { + session, _ := governanceTypedSession(t, tc.tool, BaseDeps{}, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.True(t, result.IsError) + require.NotEmpty(t, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + }) + } + }) + } +} + +func TestNormalizeGovernanceLevelRouting(t *testing.T) { + cases := []struct { + name string + in string + out string + }{ + { + name: "organization level drops repository-only owner/repo", + in: `{"level":"organization","org":"o","owner":42,"repo":false}`, + out: `{"level":"organization","org":"o"}`, + }, + { + name: "enterprise level drops repository and organization fields", + in: `{"level":"enterprise","enterprise":"e","owner":42,"repo":false,"org":123}`, + out: `{"level":"enterprise","enterprise":"e"}`, + }, + { + name: "repository level keeps owner/repo and drops org/enterprise", + in: `{"level":"repository","owner":"o","repo":"r","org":42,"enterprise":false}`, + out: `{"level":"repository","owner":"o","repo":"r"}`, + }, + { + name: "repository level leaves a wrong-typed used field for the handler to reject", + in: `{"level":"repository","owner":42,"repo":"r"}`, + out: `{"level":"repository","owner":42,"repo":"r"}`, + }, + { + name: "no change when nothing unused is present", + in: `{"level":"organization","org":"o"}`, + out: `{"level":"organization","org":"o"}`, + }, + { + name: "missing level is passed through untouched", + in: `{"owner":42,"org":123}`, + out: `{"owner":42,"org":123}`, + }, + { + name: "non-string level is passed through untouched", + in: `{"level":false,"owner":42,"org":123}`, + out: `{"level":false,"owner":42,"org":123}`, + }, + { + name: "unrecognized level value is passed through untouched", + in: `{"level":"nonsense","owner":42,"org":123}`, + out: `{"level":"nonsense","owner":42,"org":123}`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := normalizeGovernanceLevelRouting(json.RawMessage(tc.in)) + require.NoError(t, err) + assert.JSONEq(t, tc.out, string(out)) + }) + } +} + +func TestTypedGovernanceLevelRoutingNormalization(t *testing.T) { + tr := translations.NullTranslationHelper + rest := func(method, path, body string) func(*testing.T, *http.Request) string { + return func(t *testing.T, request *http.Request) string { + t.Helper() + assert.Equal(t, method, request.Method) + assert.Equal(t, path, request.URL.Path) + if request.Body != nil { + _, _ = io.Copy(io.Discard, request.Body) + } + return body + } + } + cases := []struct { + name string + tc governanceWireCase + }{ + { + name: "custom_properties_read organization ignores wrong-typed owner/repo", + tc: governanceWireCase{ + tool: CustomPropertiesRead(tr), + args: map[string]any{"level": "organization", "org": "o", "owner": 42, "repo": false}, + status: http.StatusOK, + body: `[{"property_name":"environment","value_type":"string"}]`, + text: `[{"property_name":"environment","value_type":"string"}]`, + route: rest(http.MethodGet, "/orgs/o/properties/schema", `[{"property_name":"environment","value_type":"string"}]`), + }, + }, + { + name: "custom_properties_read enterprise ignores wrong-typed owner/repo/org", + tc: governanceWireCase{ + tool: CustomPropertiesRead(tr), + args: map[string]any{"level": "enterprise", "enterprise": "e", "owner": 42, "repo": false, "org": 7}, + status: http.StatusOK, + body: `[{"property_name":"classification","value_type":"single_select"}]`, + text: `[{"property_name":"classification","value_type":"single_select"}]`, + route: rest(http.MethodGet, "/enterprises/e/properties/schema", `[{"property_name":"classification","value_type":"single_select"}]`), + }, + }, + { + name: "repository_ruleset_read organization ignores wrong-typed owner/repo", + tc: governanceWireCase{ + tool: RepositoryRulesetRead(tr), + args: map[string]any{"level": "organization", "method": "list", "org": "o", "owner": 42, "repo": false}, + status: http.StatusOK, + body: `[{"id":8,"name":"org rules"}]`, + text: `[{"id":8,"name":"org rules","enforcement":"","source":""}]`, + route: rest(http.MethodGet, "/orgs/o/rulesets", `[{"id":8,"name":"org rules"}]`), + }, + }, + { + name: "create_repository_ruleset organization ignores wrong-typed owner/repo", + tc: governanceWireCase{ + tool: CreateRepositoryRuleset(tr), + args: map[string]any{ + "level": "organization", "org": "o", "owner": 42, "repo": false, + "name": "main", "enforcement": "active", "rules": []any{map[string]any{"type": "creation"}}, + }, + status: http.StatusCreated, + body: `{"id":8,"name":"main","source":"org:o","enforcement":"active","rules":[{"type":"creation"}]}`, + text: `{"id":8,"name":"main","source":"org:o","enforcement":"active","rules":[{"type":"creation"}]}`, + route: rest(http.MethodPost, "/orgs/o/rulesets", `{"id":8,"name":"main","source":"org:o","enforcement":"active","rules":[{"type":"creation"}]}`), + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + session, _ := governanceTypedSession(t, tc.tc.tool, governanceWireDeps(t, tc.tc), inventory.ProtocolVersionMultiRoundTrip) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tc.tool.Tool.Name, Arguments: tc.tc.args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + + legacy := governanceLegacyTool(tc.tc.tool.Tool.Name) + legacyDeps := governanceWireDeps(t, tc.tc) + legacyResult, err := legacy.Handler(legacyDeps)(ContextWithDeps(context.Background(), legacyDeps), new(createMCPRequest(tc.tc.args))) + require.NoError(t, err) + require.False(t, legacyResult.IsError, mustMarshalJSON(t, legacyResult)) + assert.JSONEq(t, getTextResult(t, legacyResult).Text, tc.tc.text) + }) + } +} + +func TestTypedGovernanceLabelRetainsIFCMetadata(t *testing.T) { + for _, tc := range governanceWireCases(t)[:2] { + t.Run(tc.tool.Tool.Name, func(t *testing.T) { + deps := governanceWireDeps(t, tc) + deps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + session, _ := governanceTypedSession(t, tc.tool, deps, inventory.ProtocolVersionMultiRoundTrip) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.False(t, result.IsError) + legacyDeps := governanceWireDeps(t, tc) + legacyDeps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + legacy := governanceLegacyTool(tc.tool.Tool.Name) + before, err := legacy.Handler(legacyDeps)(ContextWithDeps(context.Background(), legacyDeps), new(createMCPRequest(tc.args))) + require.NoError(t, err) + require.NotNil(t, result.Meta["ifc"]) + assert.JSONEq(t, mustMarshalJSON(t, before.Meta["ifc"]), mustMarshalJSON(t, result.Meta["ifc"])) + assertGovernanceWireText(t, true, getTextResult(t, before).Text, result) + }) + } +} + +func TestTypedGetLabelToolsetVariant(t *testing.T) { + issuesTool := GetLabel(translations.NullTranslationHelper) + labelsTool := GetLabelForLabelsToolset(translations.NullTranslationHelper) + assert.Equal(t, ToolsetMetadataIssues.ID, issuesTool.Toolset.ID) + assert.Equal(t, ToolsetLabels.ID, labelsTool.Toolset.ID) + assert.Equal(t, issuesTool.Tool.Name, labelsTool.Tool.Name) + require.NotNil(t, issuesTool.Tool.OutputSchema) + require.NotNil(t, labelsTool.Tool.OutputSchema) + assert.JSONEq(t, mustMarshalJSON(t, issuesTool.Tool.OutputSchema), mustMarshalJSON(t, labelsTool.Tool.OutputSchema)) +} + +func TestTypedGovernanceOutputSchemasRejectMismatches(t *testing.T) { + for _, tc := range []struct { + name string + schema *jsonschema.Schema + bad string + }{ + {"get label", labelOutputSchema(), `{"name":1,"color":"red","description":"desc"}`}, + {"list labels", listLabelsOutputSchema(), `{"labels":[],"totalCount":"1"}`}, + {"label write", labelWriteOutputSchema(), `{"message":1}`}, + {"custom properties read", customPropertiesReadOutputSchema(), `"not an array"`}, + {"property name", customPropertiesReadOutputSchema(), `{"method":"read","level":"repository","values":[{"property_name":123,"value":"prod"}]}`}, + {"property value", customPropertiesReadOutputSchema(), `{"method":"read","level":"repository","values":[{"property_name":"environment","value":false}]}`}, + {"property definition", customPropertiesReadOutputSchema(), `{"method":"read","level":"organization","definitions":[{"property_name":"environment","value_type":false}]}`}, + {"written definition", customPropertiesWriteOutputSchema(), `{"method":"write","level":"organization","definitions":[{"property_name":123,"value_type":"string"}]}`}, + {"custom properties write", customPropertiesWriteOutputSchema(), `1`}, + {"ruleset read", rulesetReadOutputSchema(), `true`}, + {"ruleset fields", rulesetReadOutputSchema(), `{"method":"get","level":"repository","ruleset":{"id":"invalid","rules":false}}`}, + {"ruleset list", rulesetReadOutputSchema(), `{"method":"list","level":"repository","rulesets":[{"id":"invalid","name":"main","source":"","enforcement":"active"}]}`}, + {"enterprise count", rulesetReadOutputSchema(), `{"method":"list","level":"enterprise","total_count":"invalid","rulesets":[]}`}, + {"suite fields", rulesetReadOutputSchema(), `{"method":"get_rule_suite","level":"repository","rule_suite":{"id":"invalid","result":"pass"}}`}, + {"branch fields", rulesetReadOutputSchema(), `{"method":"get_rules_for_branch","level":"repository","rules":[{"type":false}]}`}, + {"create ruleset", createdRulesetOutputSchema(), `{"rules":false}`}, + } { + t.Run(tc.name, func(t *testing.T) { + resolved, err := tc.schema.Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(tc.bad), &value)) + assert.Error(t, resolved.Validate(value)) + }) + } +} + +func TestTypedGovernanceEmptyNullAndValueUnions(t *testing.T) { + tr := translations.NullTranslationHelper + cases := []struct { + tool inventory.ServerTool + args map[string]any + body string + field string + expected string + }{ + {CustomPropertiesRead(tr), map[string]any{"level": "repository", "owner": "o", "repo": "r"}, "[]", "values", "[]"}, + {CustomPropertiesRead(tr), map[string]any{"level": "repository", "owner": "o", "repo": "r"}, "null", "values", "[]"}, + {CustomPropertiesRead(tr), map[string]any{"level": "repository", "owner": "o", "repo": "r"}, `[{"property_name":"cleared","value":null},{"property_name":"multi","value":["a","b"]}]`, "values", `[{"property_name":"cleared","value":null},{"property_name":"multi","value":["a","b"]}]`}, + {CustomPropertiesRead(tr), map[string]any{"level": "organization", "org": "o", "owner": false}, "[]", "definitions", "[]"}, + {CustomPropertiesRead(tr), map[string]any{"level": "enterprise", "enterprise": "e"}, "null", "definitions", "[]"}, + {CustomPropertiesRead(tr), map[string]any{"level": "organization", "org": "o"}, `[{"property_name":"classification","value_type":"multi_select","default_value":null,"allowed_values":[]}]`, "definitions", `[{"property_name":"classification","value_type":"multi_select","default_value":null,"allowed_values":[]}]`}, + {CustomPropertiesWrite(tr), map[string]any{"level": "organization", "org": "o", "properties": []any{}}, "[]", "definitions", "[]"}, + {CustomPropertiesWrite(tr), map[string]any{"level": "enterprise", "enterprise": "e", "properties": []any{}}, "null", "definitions", "[]"}, + {CustomPropertiesWrite(tr), map[string]any{"level": "enterprise", "enterprise": "e", "properties": []any{map[string]any{"property_name": "classification", "value_type": "string"}}}, `[{"property_name":"classification","value_type":"string","default_value":null,"allowed_values":[]}]`, "definitions", `[{"property_name":"classification","value_type":"string","default_value":null,"allowed_values":[]}]`}, + {RepositoryRulesetRead(tr), map[string]any{"level": "repository", "method": "LIST", "owner": "o", "repo": "r"}, "[]", "rulesets", "[]"}, + {RepositoryRulesetRead(tr), map[string]any{"level": "organization", "method": "list", "org": "o"}, "null", "rulesets", "[]"}, + {RepositoryRulesetRead(tr), map[string]any{"level": "enterprise", "method": "list", "enterprise": "e"}, `{"total_count":0,"rulesets":null}`, "rulesets", "[]"}, + {RepositoryRulesetRead(tr), map[string]any{"level": "repository", "method": "list_rule_suites", "owner": "o", "repo": "r"}, "[]", "rule_suites", "[]"}, + {RepositoryRulesetRead(tr), map[string]any{"level": "organization", "method": "list_rule_suites", "org": "o"}, "null", "rule_suites", "[]"}, + {RepositoryRulesetRead(tr), map[string]any{"level": "organization", "method": "get_rule_suite", "org": "o", "rule_suite_id": 11}, "null", "", ""}, + } + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} { + for _, tc := range cases { + t.Run(protocol+"/"+tc.tool.Tool.Name+"/"+tc.body, func(t *testing.T) { + wire := governanceWireCase{tool: tc.tool, args: tc.args, route: func(_ *testing.T, _ *http.Request) string { return tc.body }} + session, schema := governanceTypedSession(t, tc.tool, governanceWireDeps(t, wire), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.False(t, result.IsError) + legacyDeps := governanceWireDeps(t, wire) + legacy := governanceLegacyTool(tc.tool.Tool.Name) + before, err := legacy.Handler(legacyDeps)(ContextWithDeps(context.Background(), legacyDeps), new(createMCPRequest(tc.args))) + require.NoError(t, err) + assertGovernanceWireText(t, schema != nil, getTextResult(t, before).Text, result) + if schema == nil { + assert.Nil(t, result.StructuredContent) + return + } + var output map[string]any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + if tc.field != "" { + assert.JSONEq(t, tc.expected, mustMarshalJSON(t, output[tc.field])) + } + }) + } + } +} + +func TestTypedGovernanceAPIErrors(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} { + for _, tc := range governanceWireCases(t) { + t.Run(protocol+"/"+tc.tool.Tool.Name, func(t *testing.T) { + tc.status = http.StatusForbidden + tc.body = `{"message":"Forbidden"}` + tc.route = nil + if tc.tool.Tool.Name == "get_label" || tc.tool.Tool.Name == "list_label" || tc.tool.Tool.Name == "label_write" { + tc.route = func(_ *testing.T, _ *http.Request) string { return `{"errors":[{"message":"Forbidden"}]}` } + } + session, _ := governanceTypedSession(t, tc.tool, governanceWireDeps(t, tc), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + legacyDeps := governanceWireDeps(t, tc) + legacy := governanceLegacyTool(tc.tool.Tool.Name) + before, err := legacy.Handler(legacyDeps)(ContextWithDeps(context.Background(), legacyDeps), new(createMCPRequest(tc.args))) + require.NoError(t, err) + assert.Equal(t, getTextResult(t, before).Text, getTextResult(t, result).Text) + }) + } + } +} + +func TestTypedGovernanceCompactContracts(t *testing.T) { + for _, tool := range []inventory.ServerTool{ + GetLabel(translations.NullTranslationHelper), GetLabelForLabelsToolset(translations.NullTranslationHelper), + ListLabels(translations.NullTranslationHelper), LabelWrite(translations.NullTranslationHelper), + CustomPropertiesRead(translations.NullTranslationHelper), CustomPropertiesWrite(translations.NullTranslationHelper), + RepositoryRulesetRead(translations.NullTranslationHelper), CreateRepositoryRuleset(translations.NullTranslationHelper), + } { + t.Run(tool.Tool.Name, func(t *testing.T) { + schema := tool.Tool.OutputSchema.(*jsonschema.Schema) + assert.Equal(t, "object", schema.Type) + assert.Empty(t, schema.AnyOf) + assert.Empty(t, schema.OneOf) + if schema.Properties["method"] != nil { + assert.Contains(t, schema.Required, "method") + assert.NotEmpty(t, schema.Properties["method"].Enum) + } + _, err := schema.Resolve(nil) + require.NoError(t, err) + }) + } + legacyJSON := `{"id":7,"name":"main","source":"o/r","enforcement":"active","node_id":"NODE","_links":{"self":{"href":"https://api.github.com/repos/o/r/rulesets/7"}},"rules":[{"type":"commit_message_pattern","parameters":{"operator":"regex","pattern":"^ship","custom":{"nested":[1,true,null]}}}],"conditions":{"ref_name":{"include":["refs/heads/main"],"exclude":[]}}}` + output, err := decodeGovernanceJSON[RulesetOutput]([]byte(legacyJSON), nil) + require.NoError(t, err) + encoded := mustMarshalJSON(t, output) + assert.NotContains(t, encoded, "https://") + assert.NotContains(t, encoded, "node_id") + assert.Contains(t, encoded, `"custom":{"nested":[1,true,null]}`) + assert.Less(t, len(encoded), len(legacyJSON)) + resolved, err := createdRulesetOutputSchema().Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(encoded), &value)) + require.NoError(t, resolved.Validate(value)) + t.Logf("representative ruleset payload: legacy=%d bytes, modern=%d bytes", len(legacyJSON), len(encoded)) +} + +func TestTypedGovernancePropertyValueContracts(t *testing.T) { + for _, raw := range []string{`"prod"`, `["prod","staging"]`, `[]`, `null`} { + t.Run(raw, func(t *testing.T) { + var value CustomPropertyOutputValue + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + assert.JSONEq(t, raw, mustMarshalJSON(t, value)) + }) + } + for _, raw := range []string{`false`, `42`, `{}`, `["prod",42]`} { + t.Run("invalid/"+raw, func(t *testing.T) { + var value CustomPropertyOutputValue + require.Error(t, json.Unmarshal([]byte(raw), &value)) + }) + } + for _, raw := range []string{ + `{"property_name":"classification","value_type":"multi_select","default_value":null,"allowed_values":[]}`, + `{"property_name":"classification","value_type":"multi_select","default_value":["a"],"allowed_values":["a"]}`, + `{"property_name":"environment","value_type":"string","required":false,"require_explicit_values":false}`, + } { + t.Run(raw, func(t *testing.T) { + var definition CustomPropertyDefinitionOutput + require.NoError(t, json.Unmarshal([]byte(raw), &definition)) + assert.JSONEq(t, raw, mustMarshalJSON(t, definition)) + schema, err := customPropertiesReadOutputSchema().Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(`{"method":"read","level":"organization","definitions":[`+mustMarshalJSON(t, definition)+`]}`), &value)) + require.NoError(t, schema.Validate(value)) + }) + } +} + +func TestTypedGovernanceNullableProjectionContracts(t *testing.T) { + for _, tc := range []struct { + method string + level string + raw string + expected string + }{ + {"get", "repository", "null", `{"method":"get","level":"repository"}`}, + {"list", "repository", "null", `{"method":"list","level":"repository","rulesets":[]}`}, + {"list", "enterprise", `{"total_count":0,"rulesets":null}`, `{"method":"list","level":"enterprise","total_count":0,"rulesets":[]}`}, + {"get_rules_for_branch", "repository", "null", `{"method":"get_rules_for_branch","level":"repository","rules":[]}`}, + {"list_rule_suites", "organization", "null", `{"method":"list_rule_suites","level":"organization","rule_suites":[]}`}, + {"get_rule_suite", "organization", "null", `{"method":"get_rule_suite","level":"organization"}`}, + } { + t.Run(tc.method+"/"+tc.level, func(t *testing.T) { + args := map[string]json.RawMessage{"method": json.RawMessage(`"` + tc.method + `"`), "level": json.RawMessage(`"` + tc.level + `"`)} + output, err := decodeRulesetRead([]byte(tc.raw), args) + require.NoError(t, err) + assert.JSONEq(t, tc.expected, mustMarshalJSON(t, output)) + resolved, err := rulesetReadOutputSchema().Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, output)), &value)) + require.NoError(t, resolved.Validate(value)) + }) + } + for _, level := range []string{"repository", "organization", "enterprise"} { + for _, raw := range []string{"null", "[]"} { + t.Run("properties/"+level+"/"+raw, func(t *testing.T) { + output, err := decodeCustomProperties("read")([]byte(raw), map[string]json.RawMessage{"level": json.RawMessage(`"` + level + `"`)}) + require.NoError(t, err) + expected := `{"method":"read","level":"` + level + `","definitions":[]}` + if level == "repository" { + expected = `{"method":"read","level":"repository","values":[]}` + } + assert.JSONEq(t, expected, mustMarshalJSON(t, output)) + }) + } + } +} diff --git a/pkg/github/typed_granular_issue_outputs_test.go b/pkg/github/typed_granular_issue_outputs_test.go new file mode 100644 index 0000000000..d2b833d650 --- /dev/null +++ b/pkg/github/typed_granular_issue_outputs_test.go @@ -0,0 +1,467 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "strings" + "testing" + + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/v2/pkg/http/transport" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func granularIssueSession(t *testing.T, deps BaseDeps, protocol string) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + tools := granularToolsForToolset(ToolsetMetadataIssues.ID, FeatureFlagIssuesGranular) + require.Len(t, tools, 18) + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}). + WithFeatureChecker(featureCheckerFor(inventory.FeatureFlag(FeatureFlagIssuesGranular))).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "granular-issues", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + if protocol == "" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + switch req := request.(type) { + case *mcp.ListToolsRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + case *mcp.CallToolRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + } + return next(ctx, method, request) + } + }) + } + version := protocol + if version == "" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, tool.Name) + require.NoError(t, toolsnaps.Test(tool.Name, *tool)) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err, tool.Name) + schemas[tool.Name] = resolved + if tool.Name == "add_sub_issue" { + schemaJSON := mustMarshalJSON(t, tool.OutputSchema) + for _, excluded := range []string{`"url"`, `"comments_url"`, `"events_url"`, `"labels_url"`, `"repository_url"`, `"profile_url"`, `"avatar_url"`, `"pinned_comment"`, `"performed_via_github_app"`} { + assert.NotContains(t, schemaJSON, excluded) + } + assert.Less(t, len(schemaJSON), 12000) + } + } + return session, schemas +} + +type granularIssueWireCase struct { + name string + args map[string]any + text string + structured string + payload string + path string + method string +} + +const granularMutationText = `{"id":"123","url":"https://github.com/owner/repo/issues/1"}` +const granularSubIssueAPIResponse = `{"id":7,"number":7,"title":"Child\u202e","body":"body\u202e","url":"https://api.github.com/repos/owner/repo/issues/7","html_url":"https://github.com/owner/repo/issues/7","comments_url":"https://api.github.com/repos/owner/repo/issues/7/comments","node_id":"I_7","issue_field_values":[{"issue_field_id":11,"node_id":"IF_11","data_type":"number","value":0},{"issue_field_id":12,"node_id":"IF_12","data_type":"text","value":""},{"issue_field_id":13,"node_id":"IF_13","data_type":"text","value":false},{"issue_field_id":14,"node_id":"IF_14","data_type":"text","value":null}]}` +const granularSubIssueText = `{"id":7,"number":7,"title":"Child","body":"body","url":"https://api.github.com/repos/owner/repo/issues/7","html_url":"https://github.com/owner/repo/issues/7","comments_url":"https://api.github.com/repos/owner/repo/issues/7/comments","node_id":"I_7","issue_field_values":[{"issue_field_id":11,"node_id":"IF_11","data_type":"number","value":0},{"issue_field_id":12,"node_id":"IF_12","data_type":"text","value":""},{"issue_field_id":13,"node_id":"IF_13","data_type":"text","value":false},{"issue_field_id":14,"node_id":"IF_14","data_type":"text","value":null}]}` + +const granularSubIssueStructured = `{"method":"add","issue":{"id":7,"number":7,"title":"Child","body":"body","html_url":"https://github.com/owner/repo/issues/7","assignees":[],"field_values":[{"issue_field_id":11,"data_type":"number","value":0},{"issue_field_id":12,"data_type":"text","value":""},{"issue_field_id":13,"data_type":"text","value":false},{"issue_field_id":14,"data_type":"text","value":null}]}}` + +func granularIssueWireCases() []granularIssueWireCase { + cases := []granularIssueWireCase{ + {"create_issue", map[string]any{"title": "Subject"}, granularMutationText, "", `{"title":"Subject"}`, "/repos/owner/repo/issues", "POST"}, + {"create_issue", map[string]any{"title": "Subject", "body": "Body"}, granularMutationText, "", `{"title":"Subject","body":"Body"}`, "/repos/owner/repo/issues", "POST"}, + {"create_issue", map[string]any{"title": "Subject", "body": ""}, granularMutationText, "", `{"title":"Subject"}`, "/repos/owner/repo/issues", "POST"}, + {"create_issue", map[string]any{"title": "Subject", "parent_issue_number": "7.0"}, granularMutationText, "", "", "", ""}, + {"create_issue", map[string]any{"title": "Subject", "body": "Body", "parent_issue_number": "7", "parent_owner": "other", "parent_repo": "parent"}, granularMutationText, "", "", "", ""}, + {"update_issue_title", map[string]any{"title": "Changed"}, granularMutationText, "", `{"title":"Changed"}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_body", map[string]any{"body": "Changed"}, granularMutationText, "", `{"body":"Changed"}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_assignees", map[string]any{"assignees": []string{}}, granularMutationText, "", `{"assignees":[]}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_assignees", map[string]any{"assignees": []any{"octocat", map[string]any{"login": "hubot"}}}, granularMutationText, "", `{"assignees":["octocat","hubot"]}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_assignees", map[string]any{"assignees": []any{"hubot", map[string]any{"login": "octocat", "rationale": " Authored file ", "confidence": " high\t", "is_suggestion": true}}}, granularMutationText, "", `{"assignees":["hubot",{"login":"octocat","rationale":"Authored file","confidence":"HIGH","suggest":true}]}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_labels", map[string]any{"labels": []string{}}, granularMutationText, "", `{"labels":[]}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_labels", map[string]any{"labels": []any{"bug", map[string]any{"name": "triage"}}}, granularMutationText, "", `{"labels":["bug","triage"]}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_labels", map[string]any{"labels": []any{"triage", map[string]any{"name": "bug", "rationale": " Crash ", "confidence": " medium ", "is_suggestion": true}}}, granularMutationText, "", `{"labels":["triage",{"name":"bug","rationale":"Crash","confidence":"MEDIUM","suggest":true}]}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_milestone", map[string]any{"milestone": "3e0"}, granularMutationText, "", `{"milestone":3}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_type", map[string]any{"issue_type": nil}, granularMutationText, "", `{"type":null}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_type", map[string]any{"issue_type": "Bug"}, granularMutationText, "", `{"type":"Bug"}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_type", map[string]any{"issue_type": "Bug", "rationale": " Crash ", "confidence": " low ", "is_suggestion": true}, granularMutationText, "", `{"type":{"value":"Bug","rationale":"Crash","confidence":"LOW","suggest":true}}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_state", map[string]any{"state": "open"}, granularMutationText, "", `{"state":"open"}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_state", map[string]any{"state": "closed", "state_reason": "completed"}, granularMutationText, "", `{"state":"closed","state_reason":"completed"}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_state", map[string]any{"state": "closed", "state_reason": "not_planned", "confidence": " high "}, granularMutationText, "", `{"state":{"value":"closed","confidence":"HIGH"},"state_reason":"not_planned"}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_state", map[string]any{"state": "closed", "state_reason": "duplicate", "duplicate_of": "2.0", "is_suggestion": true}, granularMutationText, "", `{"state":{"value":"closed","suggest":true},"state_reason":"duplicate","duplicate_issue_id":456}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"add_sub_issue", map[string]any{"sub_issue_id": "7", "replace_parent": true}, granularSubIssueText, granularSubIssueStructured, `{"sub_issue_id":7,"replace_parent":true}`, "/repos/owner/repo/issues/1/sub_issues", "POST"}, + {"remove_sub_issue", map[string]any{"sub_issue_id": "7"}, granularSubIssueText, granularSubIssueStructured, `{"sub_issue_id":7}`, "/repos/owner/repo/issues/1/sub_issue", "DELETE"}, + {"reprioritize_sub_issue", map[string]any{"sub_issue_id": "7", "after_id": "8.0"}, granularSubIssueText, granularSubIssueStructured, `{"sub_issue_id":7,"after_id":8}`, "/repos/owner/repo/issues/1/sub_issues/priority", "PATCH"}, + {"reprioritize_sub_issue", map[string]any{"sub_issue_id": "7", "before_id": "8"}, granularSubIssueText, granularSubIssueStructured, `{"sub_issue_id":7,"before_id":8}`, "/repos/owner/repo/issues/1/sub_issues/priority", "PATCH"}, + {"set_issue_fields", map[string]any{"fields": []any{map[string]any{"field_id": "IF_text", "text_value": "Value", "rationale": " Signal ", "confidence": " high ", "is_suggestion": true}, map[string]any{"field_id": "IF_number", "number_value": 0}, map[string]any{"field_id": "IF_date", "date_value": "2026-10-02"}, map[string]any{"field_id": "IF_select", "single_select_option_id": "OPT_1"}, map[string]any{"field_id": "IF_delete", "delete": true}}}, `{"id":"I_1","url":"https://github.com/owner/repo/issues/1"}`, "", "", "", ""}, + {"add_issue_reaction", map[string]any{"content": "+1"}, `{"id":"9","url":"https://api.github.com/repos/owner/repo/issues/1/reactions/9"}`, "", `{"content":"+1"}`, "/repos/owner/repo/issues/1/reactions", "POST"}, + {"remove_issue_reaction", map[string]any{"reaction_id": "9e0"}, "reaction successfully removed from issue", `{"message":"reaction successfully removed from issue"}`, "", "/repos/owner/repo/issues/1/reactions/9", "DELETE"}, + {"add_issue_comment_reaction", map[string]any{"comment_id": "42.0", "content": "-1"}, `{"id":"9","url":"https://api.github.com/repos/owner/repo/issues/comments/42/reactions/9"}`, "", `{"content":"-1"}`, "/repos/owner/repo/issues/comments/42/reactions", "POST"}, + {"remove_issue_comment_reaction", map[string]any{"comment_id": "42", "reaction_id": "9"}, "reaction successfully removed from issue comment", `{"message":"reaction successfully removed from issue comment"}`, "", "/repos/owner/repo/issues/comments/42/reactions/9", "DELETE"}, + {"hide_issue_comment", map[string]any{"comment_id": "42", "classifier": "oFf_ToPiC"}, `{"node_id":"NODE_1","is_minimized":true,"minimized_reason":"off-topic"}`, "", "", "", ""}, + {"unhide_issue_comment", map[string]any{"comment_id": "42"}, `{"node_id":"NODE_1","is_minimized":false}`, "", "", "", ""}, + } + for _, reaction := range []string{"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"} { + cases = append(cases, + granularIssueWireCase{ + name: "add_issue_reaction", args: map[string]any{"content": reaction}, + text: `{"id":"9","url":"https://api.github.com/repos/owner/repo/issues/1/reactions/9"}`, + payload: fmt.Sprintf(`{"content":%q}`, reaction), path: "/repos/owner/repo/issues/1/reactions", method: "POST", + }, + granularIssueWireCase{ + name: "add_issue_comment_reaction", args: map[string]any{"comment_id": "42", "content": reaction}, + text: `{"id":"9","url":"https://api.github.com/repos/owner/repo/issues/comments/42/reactions/9"}`, + payload: fmt.Sprintf(`{"content":%q}`, reaction), path: "/repos/owner/repo/issues/comments/42/reactions", method: "POST", + }) + } + return cases +} + +func granularWireDeps(t *testing.T, current **granularIssueWireCase, fail bool) BaseDeps { + t.Helper() + rest := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tc := *current + if fail { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + return + } + if r.Method == "GET" && r.URL.Path == "/repos/owner/repo/issues/2" { + _, _ = w.Write([]byte(`{"id":456,"number":2}`)) + return + } + if strings.HasSuffix(r.URL.Path, "/comments/42") { + assert.Equal(t, "GET", r.Method) + _, _ = w.Write([]byte(`{"node_id":"NODE_1"}`)) + return + } + require.Equal(t, tc.path, r.URL.Path) + require.Equal(t, tc.method, r.Method) + if tc.payload != "" { + var body json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.JSONEq(t, tc.payload, string(body)) + } + if strings.Contains(tc.name, "remove_issue") { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method == "POST" { + w.WriteHeader(http.StatusCreated) + } + switch { + case strings.Contains(tc.name, "sub_issue"): + _, _ = w.Write([]byte(granularSubIssueAPIResponse)) + case strings.Contains(tc.name, "reaction"): + _, _ = w.Write([]byte(`{"id":9}`)) + default: + _, _ = w.Write([]byte(`{"id":123,"html_url":"https://github.com/owner/repo/issues/1"}`)) + } + })}} + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if fail { + _, _ = w.Write([]byte(`{"errors":[{"message":"Forbidden"}]}`)) + return + } + tc := *current + var request struct { + Query string `json:"query"` + Variables map[string]json.RawMessage `json:"variables"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + switch { + case strings.Contains(request.Query, "parentRepository:"): + parentOwner, parentRepo := "owner", "repo" + if tc.args["parent_owner"] != nil { + parentOwner, parentRepo = tc.args["parent_owner"].(string), tc.args["parent_repo"].(string) + } + assert.JSONEq(t, fmt.Sprintf(`"%s"`, parentOwner), string(request.Variables["parentOwner"])) + assert.JSONEq(t, fmt.Sprintf(`"%s"`, parentRepo), string(request.Variables["parentRepo"])) + assert.JSONEq(t, `7`, string(request.Variables["parentIssueNumber"])) + _, _ = w.Write([]byte(`{"data":{"childRepository":{"id":"R_1","nameWithOwner":"owner/repo"},"parentRepository":{"issue":{"id":"I_parent","number":7}}}}`)) + case strings.Contains(request.Query, "createIssue("): + var input map[string]any + require.NoError(t, json.Unmarshal(request.Variables["input"], &input)) + assert.Equal(t, "R_1", input["repositoryId"]) + assert.Equal(t, "I_parent", input["parentIssueId"]) + assert.Equal(t, "Subject", input["title"]) + if tc.args["body"] != nil { + assert.Equal(t, tc.args["body"], input["body"]) + } + _, _ = w.Write([]byte(`{"data":{"createIssue":{"issue":{"fullDatabaseId":"123","url":"https://github.com/owner/repo/issues/1"}}}}`)) + case strings.Contains(request.Query, "setIssueFieldValue("): + assert.Contains(t, r.Header.Get(headers.GraphQLFeaturesHeader), "update_issue_suggestions") + assert.JSONEq(t, `{"issueId":"I_1","issueFields":[{"fieldId":"IF_text","textValue":"Value","rationale":"Signal","confidence":"HIGH","suggest":true},{"fieldId":"IF_number","numberValue":0},{"fieldId":"IF_date","dateValue":"2026-10-02"},{"fieldId":"IF_select","singleSelectOptionId":"OPT_1"},{"fieldId":"IF_delete","delete":true}]}`, string(request.Variables["input"])) + _, _ = w.Write([]byte(`{"data":{"setIssueFieldValue":{"issue":{"id":"I_1","url":"https://github.com/owner/repo/issues/1"}}}}`)) + case strings.Contains(request.Query, "unminimizeComment("): + _, _ = w.Write([]byte(`{"data":{"unminimizeComment":{"unminimizedComment":{"isMinimized":false}}}}`)) + case strings.Contains(request.Query, "minimizeComment("): + _, _ = w.Write([]byte(`{"data":{"minimizeComment":{"minimizedComment":{"isMinimized":true,"minimizedReason":"off-topic"}}}}`)) + case strings.Contains(request.Query, "issue(number:"): + _, _ = w.Write([]byte(`{"data":{"repository":{"issue":{"id":"I_1"}}}}`)) + default: + t.Errorf("unexpected GraphQL query: %s", request.Query) + } + })}} + gql.Transport = &transportpkg.GraphQLFeaturesTransport{Transport: gql.Transport} + return BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: githubv4.NewClient(gql)} +} + +func TestTypedGranularIssueWireOutputs(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + t.Run("protocol="+protocol, func(t *testing.T) { + var current *granularIssueWireCase + session, schemas := granularIssueSession(t, granularWireDeps(t, ¤t, false), protocol) + for i, tc := range granularIssueWireCases() { + t.Run(fmt.Sprintf("%s/%d", tc.name, i), func(t *testing.T) { + current = &tc + args := map[string]any{"owner": "owner", "repo": "repo", "issue_number": "1.0"} + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + expected := tc.text + if schema := schemas[tc.name]; schema != nil { + if tc.structured != "" { + expected = tc.structured + } else { + expected = tc.text + } + if strings.Contains(tc.name, "sub_issue") { + method := map[string]string{"add_sub_issue": "add", "remove_sub_issue": "remove", "reprioritize_sub_issue": "reprioritize"}[tc.name] + expected = strings.Replace(expected, `"method":"add"`, fmt.Sprintf(`"method":%q`, method), 1) + } + require.NotNil(t, result.StructuredContent) + assert.JSONEq(t, expected, mustMarshalJSON(t, result.StructuredContent)) + assert.JSONEq(t, expected, getTextResult(t, result).Text) + assert.JSONEq(t, getTextResult(t, result).Text, mustMarshalJSON(t, result.StructuredContent)) + require.NoError(t, schema.Validate(result.StructuredContent)) + if strings.Contains(tc.name, "sub_issue") { + var output map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + for _, excluded := range []string{"url", "comments_url", "events_url", "labels_url", "repository_url", "node_id"} { + assert.NotContains(t, output, excluded) + } + } + } else { + assert.Equal(t, expected, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } + }) + } + }) + } +} + +func TestGranularSubIssueStructuredSchema(t *testing.T) { + resolved, err := subIssueWriteOutputSchema().Resolve(nil) + require.NoError(t, err) + for _, raw := range []string{ + `{"method":"add","issue":null}`, + `{"method":"add","issue":{"id":7,"number":7,"state":"open","title":"Child","html_url":"https://github.com/owner/repo/issues/7","assignees":[],"field_values":[]}}`, + `{"method":"reprioritize","issue":{"id":7,"assignees":[],"field_values":[{"issue_field_id":11,"data_type":"number","value":0},{"issue_field_id":12,"data_type":"text","value":null}]}}`, + } { + var output any + require.NoError(t, json.Unmarshal([]byte(raw), &output)) + require.NoError(t, resolved.Validate(output), raw) + } + for _, raw := range []string{ + `{"method":"other","issue":null}`, + `{"method":"add","issue":{"state":"queued"}}`, + `{"method":"add","issue":{"url":"https://api.github.com/repos/owner/repo/issues/7"}}`, + `{"method":"add","issue":{"comments_url":"https://api.github.com/repos/owner/repo/issues/7/comments"}}`, + } { + var output any + require.NoError(t, json.Unmarshal([]byte(raw), &output)) + require.Error(t, resolved.Validate(output), raw) + } +} + +func TestTypedGranularIssueVisibilityGates(t *testing.T) { + tools := granularToolsForToolset(ToolsetMetadataIssues.ID, FeatureFlagIssuesGranular) + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25"} { + for _, gate := range []string{"feature disabled", "read only", "missing repo scope"} { + t.Run(protocol+"/"+gate, func(t *testing.T) { + builder := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return gate != "feature disabled", nil }) + switch gate { + case "read only": + builder.WithReadOnly(true) + case "missing repo scope": + builder.WithFilter(CreateToolScopeFilter([]string{"public_repo"})) + } + inv, err := builder.Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "granular-gates", Version: "v1"}, nil) + inv.RegisterTools(context.Background(), server, nil) + session := connectCommentVisibilityClient(t, server, protocol) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + assert.Empty(t, list.Tools) + for _, tool := range tools { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name}) + if err == nil { + require.NotNil(t, result) + assert.True(t, result.IsError, tool.Tool.Name) + assert.Nil(t, result.StructuredContent, tool.Tool.Name) + } + } + }) + } + } +} + +func TestTypedGranularIssueWireErrors(t *testing.T) { + cases := []struct { + name string + args map[string]any + text string + }{ + {"create_issue", map[string]any{}, "missing required parameter: title"}, + {"create_issue", map[string]any{"title": "Subject", "parent_issue_number": 0}, "parent_issue_number must be greater than 0"}, + {"create_issue", map[string]any{"title": "Subject", "parent_issue_number": nil}, "not a valid number"}, + {"create_issue", map[string]any{"title": "Subject", "parent_owner": "other"}, "can only be used"}, + {"create_issue", map[string]any{"title": "Subject", "parent_issue_number": 7, "parent_repo": "parent"}, "must be provided together"}, + {"update_issue_title", map[string]any{"title": ""}, "missing required parameter: title"}, + {"update_issue_body", map[string]any{"body": ""}, "missing required parameter: body"}, + {"update_issue_milestone", map[string]any{"milestone": "1.5"}, "non-integer"}, + {"update_issue_assignees", map[string]any{"assignees": []any{map[string]any{"login": "octo", "confidence": "maybe"}}}, "confidence must be one of"}, + {"update_issue_labels", map[string]any{"labels": []any{map[string]any{"name": "bug", "rationale": strings.Repeat("x", 281)}}}, "280 characters"}, + {"update_issue_type", map[string]any{}, "missing required parameter: issue_type"}, + {"update_issue_type", map[string]any{"issue_type": nil, "is_suggestion": true}, "suggestion metadata is not supported"}, + {"update_issue_type", map[string]any{"issue_type": ""}, "must not be empty"}, + {"update_issue_state", map[string]any{"state": "CLOSED"}, "closed"}, + {"update_issue_state", map[string]any{"state": "open", "state_reason": "completed"}, "state_reason can only"}, + {"update_issue_state", map[string]any{"state": "closed", "state_reason": "duplicate", "is_suggestion": true}, "duplicate_of is required"}, + {"update_issue_state", map[string]any{"state": "closed", "duplicate_of": 2}, "duplicate_of can only"}, + {"add_sub_issue", map[string]any{}, "missing required parameter: sub_issue_id"}, + {"remove_sub_issue", map[string]any{"sub_issue_id": "7.5"}, "non-integer"}, + {"reprioritize_sub_issue", map[string]any{"sub_issue_id": 7}, "either after_id or before_id"}, + {"reprioritize_sub_issue", map[string]any{"sub_issue_id": 7, "after_id": 8, "before_id": 9}, "only one"}, + {"set_issue_fields", map[string]any{"fields": []any{}}, "fields array must not be empty"}, + {"set_issue_fields", map[string]any{"fields": []any{map[string]any{"field_id": "IF_1", "text_value": ""}}}, "each field must have a value"}, + {"set_issue_fields", map[string]any{"fields": []any{map[string]any{"field_id": "IF_1", "text_value": "one", "number_value": 0}}}, "exactly one value"}, + {"set_issue_fields", map[string]any{"fields": []any{map[string]any{"field_id": "IF_1", "number_value": nil}}}, "each field must have a value"}, + {"add_issue_reaction", map[string]any{"content": "HEART"}, "heart"}, + {"remove_issue_reaction", map[string]any{"reaction_id": "1.5"}, "non-integer"}, + {"add_issue_comment_reaction", map[string]any{"comment_id": 42, "content": "invalid"}, "heart"}, + // Float-to-int overflow diagnostics differ across architectures. + {"remove_issue_comment_reaction", map[string]any{"comment_id": "9223372036854775808", "reaction_id": 9}, "comment_id"}, + } + + testGranularNullResponses := func(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + for _, payload := range []string{`null`, `{}`} { + t.Run("protocol="+protocol+"/payload="+payload, func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + w.WriteHeader(http.StatusCreated) + } + _, _ = w.Write([]byte(payload)) + })}} + session, schemas := granularIssueSession(t, BaseDeps{Client: mustNewGHClient(t, client)}, protocol) + for _, tc := range []struct { + name string + args map[string]any + text string + }{ + {"create_issue", map[string]any{"title": "Subject"}, `{"id":"0","url":""}`}, + {"update_issue_title", map[string]any{"title": "Subject"}, `{"id":"0","url":""}`}, + {"update_issue_body", map[string]any{"body": "Body"}, `{"id":"0","url":""}`}, + {"update_issue_assignees", map[string]any{"assignees": []string{}}, `{"id":"0","url":""}`}, + {"update_issue_labels", map[string]any{"labels": []string{}}, `{"id":"0","url":""}`}, + {"update_issue_milestone", map[string]any{"milestone": 3}, `{"id":"0","url":""}`}, + {"update_issue_type", map[string]any{"issue_type": nil}, `{"id":"0","url":""}`}, + {"update_issue_state", map[string]any{"state": "open"}, `{"id":"0","url":""}`}, + {"add_sub_issue", map[string]any{"sub_issue_id": 7}, payload}, + {"remove_sub_issue", map[string]any{"sub_issue_id": 7}, payload}, + {"reprioritize_sub_issue", map[string]any{"sub_issue_id": 7, "after_id": 8}, payload}, + {"add_issue_reaction", map[string]any{"content": "heart"}, `{"id":"0","url":"https://api.github.com/repos/owner/repo/issues/1/reactions/0"}`}, + {"add_issue_comment_reaction", map[string]any{"comment_id": 42, "content": "heart"}, `{"id":"0","url":"https://api.github.com/repos/owner/repo/issues/comments/42/reactions/0"}`}, + } { + args := map[string]any{"owner": "owner", "repo": "repo", "issue_number": 1} + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, tc.name) + require.Len(t, result.Content, 1) + expectedText := tc.text + if strings.Contains(tc.name, "sub_issue") && schemas[tc.name] != nil { + method := map[string]string{"add_sub_issue": "add", "remove_sub_issue": "remove", "reprioritize_sub_issue": "reprioritize"}[tc.name] + expectedText = fmt.Sprintf(`{"method":%q,"issue":null}`, method) + if payload == `{}` { + expectedText = fmt.Sprintf(`{"method":%q,"issue":{"assignees":[]}}`, method) + } + } + if schemas[tc.name] != nil { + assert.JSONEq(t, expectedText, getTextResult(t, result).Text) + } else { + assert.Equal(t, expectedText, getTextResult(t, result).Text) + } + if schema := schemas[tc.name]; schema != nil { + assert.JSONEq(t, expectedText, mustMarshalJSON(t, result.StructuredContent)) + require.NoError(t, schema.Validate(result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + } + } + + }) + } + } + } + t.Run("null-responses", testGranularNullResponses) + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + t.Run("protocol="+protocol, func(t *testing.T) { + var current *granularIssueWireCase + session, _ := granularIssueSession(t, granularWireDeps(t, ¤t, true), protocol) + for _, tc := range cases { + t.Run(tc.name+"/"+tc.text, func(t *testing.T) { + args := map[string]any{"owner": "owner", "repo": "repo", "issue_number": "1"} + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: args}) + require.NoError(t, err) + require.True(t, result.IsError, mustMarshalJSON(t, result)) + assert.Nil(t, result.StructuredContent) + assert.Contains(t, getErrorResult(t, result).Text, tc.text) + }) + } + for _, tc := range granularIssueWireCases() { + current = &tc + args := map[string]any{"owner": "owner", "repo": "repo", "issue_number": "1"} + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: args}) + require.NoError(t, err) + require.True(t, result.IsError, tc.name) + assert.Nil(t, result.StructuredContent, tc.name) + assert.Contains(t, getErrorResult(t, result).Text, "Forbidden", tc.name) + } + }) + } +} diff --git a/pkg/github/typed_granular_pull_request_outputs_test.go b/pkg/github/typed_granular_pull_request_outputs_test.go new file mode 100644 index 0000000000..9585e96632 --- /dev/null +++ b/pkg/github/typed_granular_pull_request_outputs_test.go @@ -0,0 +1,571 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "strings" + "testing" + "time" + + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + gogithub "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func granularPullRequestTools() []inventory.ServerTool { + t := translations.NullTranslationHelper + return []inventory.ServerTool{ + GranularUpdatePullRequestTitle(t), GranularUpdatePullRequestBody(t), + GranularUpdatePullRequestState(t), GranularUpdatePullRequestDraftState(t), + GranularRequestPullRequestReviewers(t), GranularCreatePullRequestReview(t), + GranularSubmitPendingPullRequestReview(t), GranularDeletePendingPullRequestReview(t), + GranularAddPullRequestReviewComment(t), GranularResolveReviewThread(t), + GranularUnresolveReviewThread(t), GranularAddPullRequestReviewCommentReaction(t), + GranularRemovePullRequestReviewCommentReaction(t), + GranularResolveReviewThreadWithResolutionReason(t), + } +} + +type granularPRCase struct { + name string + args map[string]any + text string + payload string +} + +const granularPRReference = `{"id":"123","url":"https://github.com/owner/repo/pull/1"}` + +func granularPRCases() []granularPRCase { + return []granularPRCase{ + {"update_pull_request_title", map[string]any{"title": "Changed"}, granularPRReference, `{"title":"Changed"}`}, + {"update_pull_request_body", map[string]any{"body": "Body"}, granularPRReference, `{"body":"Body"}`}, + {"update_pull_request_state", map[string]any{"state": "closed"}, granularPRReference, `{"state":"closed"}`}, + {"update_pull_request_state", map[string]any{"state": "other"}, granularPRReference, `{"state":"other"}`}, + {"update_pull_request_draft_state", map[string]any{"draft": true}, "pull request converted to draft", `"pullRequestId":"PR_1"`}, + {"update_pull_request_draft_state", map[string]any{"draft": false}, "pull request marked as ready for review", `"pullRequestId":"PR_1"`}, + {"request_pull_request_reviewers", map[string]any{"reviewers": []string{"octocat", "org/team", "/bad", "org/a/b"}}, granularPRReference, `{"reviewers":["octocat","/bad","org/a/b"],"team_reviewers":["team"]}`}, + {"create_pull_request_review", map[string]any{}, "pending pull request created", `"pullRequestId":"PR_1"`}, + {"create_pull_request_review", map[string]any{"event": "APPROVE", "body": "LGTM", "commitID": "abc"}, "pull request review submitted successfully", `"event":"APPROVE"`}, + {"create_pull_request_review", map[string]any{"event": "COMMENT", "body": "Note"}, "pull request review submitted successfully", `"body":"Note"`}, + {"create_pull_request_review", map[string]any{"event": "REQUEST_CHANGES"}, "pull request review submitted successfully", `"event":"REQUEST_CHANGES"`}, + {"create_pull_request_review", map[string]any{"event": 3, "body": false, "commitID": []any{}}, "pending pull request created", `"pullRequestId":"PR_1"`}, + {"create_pull_request_review", map[string]any{"event": "", "body": "ignored"}, "pending pull request created", `"pullRequestId":"PR_1"`}, + {"submit_pending_pull_request_review", map[string]any{"event": "APPROVE", "body": "LGTM"}, "pending pull request review successfully submitted", `"pullRequestReviewId":"R_1"`}, + {"submit_pending_pull_request_review", map[string]any{"event": "COMMENT", "body": false}, "pending pull request review successfully submitted", `"body":""`}, + {"submit_pending_pull_request_review", map[string]any{"event": "REQUEST_CHANGES"}, "pending pull request review successfully submitted", `"event":"REQUEST_CHANGES"`}, + {"delete_pending_pull_request_review", map[string]any{}, "pending pull request review successfully deleted", `"pullRequestReviewId":"R_1"`}, + {"add_pull_request_review_comment", map[string]any{"path": "file", "body": "nit", "subjectType": "LINE", "line": "3e0", "side": "RIGHT", "startLine": "2.0", "startSide": "LEFT"}, "pull request review comment successfully added to pending review", `"line":3`}, + {"add_pull_request_review_comment", map[string]any{"path": "file", "body": "nit", "subjectType": "FILE", "line": 0, "startLine": 0, "side": 3, "startSide": false}, "pull request review comment successfully added to pending review", `"subjectType":"FILE"`}, + {"resolve_review_thread", map[string]any{"threadID": "T_1"}, "review thread resolved successfully", `"threadId":"T_1"`}, + {"resolve_review_thread", map[string]any{"threadID": "T_1", "resolutionReason": ""}, "review thread resolved successfully", `"threadId":"T_1"`}, + {"unresolve_review_thread", map[string]any{"threadID": "T_1"}, "review thread unresolved successfully", `"threadId":"T_1"`}, + {"add_pull_request_review_comment_reaction", map[string]any{"comment_id": "42.0", "content": "heart"}, `{"id":"77","url":"https://api.github.com/repos/owner/repo/pulls/comments/42/reactions/77"}`, `{"content":"heart"}`}, + {"remove_pull_request_review_comment_reaction", map[string]any{"comment_id": "42", "reaction_id": "77e0"}, "reaction successfully removed from pull request review comment", ""}, + } +} + +// These expectations were run against the untyped handlers at the exact +// parent commit before migrating them. Keep equality, not substring checks. +func granularPRValidationCases() []granularPRCase { + return []granularPRCase{ + {"update_pull_request_title", map[string]any{"title": ""}, "missing required parameter: title", ""}, + {"update_pull_request_body", map[string]any{"body": nil}, "parameter body is not of type string", ""}, + {"update_pull_request_state", map[string]any{}, "missing required parameter: state", ""}, + {"update_pull_request_draft_state", map[string]any{}, "missing required parameter: draft", ""}, + {"update_pull_request_draft_state", map[string]any{"draft": nil}, "parameter draft is not of type bool, is ", ""}, + {"request_pull_request_reviewers", map[string]any{"reviewers": nil}, "missing required parameter: reviewers", ""}, + {"request_pull_request_reviewers", map[string]any{"reviewers": []any{"octocat", false}}, "parameter reviewers is not of type string, is bool", ""}, + {"create_pull_request_review", map[string]any{"pullNumber": 1.5}, "parameter pullNumber is not a valid number: non-integer numeric value: 1.5", ""}, + {"submit_pending_pull_request_review", map[string]any{"event": ""}, "missing required parameter: event", ""}, + {"submit_pending_pull_request_review", map[string]any{"event": false}, "parameter event is not of type string", ""}, + {"delete_pending_pull_request_review", map[string]any{"owner": ""}, "missing required parameter: owner", ""}, + {"add_pull_request_review_comment", map[string]any{"path": "file", "body": "", "subjectType": false}, "missing required parameter: body", ""}, + {"add_pull_request_review_comment", map[string]any{"path": "file", "body": "nit", "subjectType": "LINE", "line": false, "startLine": 1.5}, "parameter line is not a valid number: expected number, got bool", ""}, + {"add_pull_request_review_comment", map[string]any{"path": "file", "body": "nit", "subjectType": "LINE", "startLine": nil}, "parameter startLine is not a valid number: expected number, got ", ""}, + {"resolve_review_thread", map[string]any{"threadID": ""}, "missing required parameter: threadID", ""}, + {"unresolve_review_thread", map[string]any{"threadID": 3}, "parameter threadID is not of type string", ""}, + {"add_pull_request_review_comment_reaction", map[string]any{"comment_id": 0, "content": nil}, "missing required parameter: comment_id", ""}, + {"add_pull_request_review_comment_reaction", map[string]any{"comment_id": "42", "content": false}, "parameter content is not of type string", ""}, + {"remove_pull_request_review_comment_reaction", map[string]any{"comment_id": "42", "reaction_id": "bad"}, "parameter reaction_id is not a valid number: invalid numeric value: bad", ""}, + } +} + +func granularPRArgs(tc granularPRCase) map[string]any { + args := map[string]any{"owner": "owner", "repo": "repo", "pullNumber": "1.0"} + maps.Copy(args, tc.args) + return args +} + +func granularPRDeps(t *testing.T, current **granularPRCase, outcome string) BaseDeps { + t.Helper() + rest := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if outcome == "error" { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + return + } + if current != nil && *current != nil && (*current).payload != "" { + var payload any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.JSONEq(t, (*current).payload, mustMarshalJSON(t, payload)) + } + path := r.URL.Path + switch { + case r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + case outcome == "empty": + _, _ = w.Write([]byte(`null`)) + case strings.Contains(path, "/reactions"): + _, _ = w.Write([]byte(`{"id":77,"content":"heart"}`)) + default: + _, _ = w.Write([]byte(`{"id":123,"html_url":"https://github.com/owner/repo/pull/1"}`)) + } + })}} + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Query string `json:"query"` + Variables json.RawMessage `json:"variables"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + if outcome == "error" || outcome == "mutation_error" && strings.HasPrefix(req.Query, "mutation") { + _, _ = w.Write([]byte(`{"errors":[{"message":"Forbidden"}]}`)) + return + } + if strings.HasPrefix(req.Query, "mutation") && current != nil && *current != nil { + assert.Contains(t, string(req.Variables), (*current).payload) + } + var body string + switch q := req.Query; { + case strings.Contains(q, "convertPullRequestToDraft("): + body = `{"data":{"convertPullRequestToDraft":{"pullRequest":{"id":"PR_1","isDraft":true}}}}` + case strings.Contains(q, "markPullRequestReadyForReview("): + body = `{"data":{"markPullRequestReadyForReview":{"pullRequest":{"id":"PR_1","isDraft":false}}}}` + case strings.Contains(q, "addPullRequestReviewThread("): + body = `{"data":{"addPullRequestReviewThread":{"thread":{"id":"T_2"}}}}` + case strings.Contains(q, "addPullRequestReview("): + body = `{"data":{"addPullRequestReview":{"pullRequestReview":{"id":"R_1"}}}}` + case strings.Contains(q, "submitPullRequestReview("): + body = `{"data":{"submitPullRequestReview":{"pullRequestReview":{"id":"R_1"}}}}` + case strings.Contains(q, "deletePullRequestReview("): + body = `{"data":{"deletePullRequestReview":{"pullRequestReview":{"id":"R_1"}}}}` + case strings.Contains(q, "unresolveReviewThread("): + body = `{"data":{"unresolveReviewThread":{"thread":{"id":"T_1","isResolved":false}}}}` + case strings.Contains(q, "resolveReviewThread("): + body = `{"data":{"resolveReviewThread":{"thread":{"id":"T_1","isResolved":true}}}}` + case strings.Contains(q, "reviews(first: 100"): + body = `{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[{"id":"R_1","author":{"userId":"U_1"}}],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}}` + if outcome == "no_pending" { + body = `{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}}` + } + case strings.Contains(q, "viewer{"): + body = `{"data":{"viewer":{"id":"U_1"}}}` + default: + require.Contains(t, q, "pullRequest(number:") + body = `{"data":{"repository":{"pullRequest":{"id":"PR_1"}}}}` + } + if outcome == "empty" { + body = `{"data":{}}` + } + _, _ = w.Write([]byte(body)) + })}} + return BaseDeps{ + Client: mustNewGHClient(t, rest), GQLClient: githubv4.NewClient(gql), + RepoAccessCache: stubRepoAccessCache(nil, time.Minute), + } +} + +func granularPRSession(t *testing.T, deps ToolDependencies, protocol string, reason bool) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + tools := granularPullRequestTools() + flags := []inventory.FeatureFlag{inventory.FeatureFlag(FeatureFlagPullRequestsGranular)} + if reason { + flags = append(flags, FeatureFlagThreadResolutionReason) + } + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}). + WithFeatureChecker(featureCheckerFor(flags...)).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "granular-pr", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + if protocol == "" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch req := req.(type) { + case *mcp.ListToolsRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + case *mcp.CallToolRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + } + return next(ctx, method, req) + } + }) + } + version := protocol + if version == "" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 13) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + name := tool.Name + if name == "resolve_review_thread" && reason { + name += "_resolution_reason" + } + // Resolve the advertised input schema as well as every modern output. + var inputSchema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.InputSchema)), &inputSchema)) + _, err := inputSchema.Resolve(nil) + require.NoError(t, err, name) + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, name) + continue + } + require.NotNil(t, tool.OutputSchema, name) + require.NoError(t, toolsnaps.Test(name, *tool)) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err, name) + schemas[tool.Name] = resolved + if tool.Name == "add_pull_request_review_comment_reaction" { + require.NoError(t, resolved.Validate(map[string]any{"id": "77", "url": "https://api.github.com/reaction"})) + require.Error(t, resolved.Validate(map[string]any{"id": float64(77), "content": "heart"})) + } + } + return session, schemas +} + +func TestGranularPullRequestCanonicalSnapshots(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + for _, reason := range []bool{false, true} { + granularPRSession(t, BaseDeps{}, protocol, reason) + } + } +} + +func assertGranularPRResult(t *testing.T, result *mcp.CallToolResult, schema *jsonschema.Resolved, text string, isError bool) { + t.Helper() + require.Equal(t, isError, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + wireText := result.Content[0].(*mcp.TextContent).Text + if schema == nil || isError { + require.Equal(t, text, wireText) + require.Nil(t, result.StructuredContent) + return + } + require.NotNil(t, result.StructuredContent) + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + switch { + case strings.Contains(text, "/reactions/"): + assert.JSONEq(t, text, mustMarshalJSON(t, output)) + assert.JSONEq(t, mustMarshalJSON(t, output), wireText) + case strings.HasPrefix(text, "{"): + assert.JSONEq(t, mustMarshalJSON(t, output), wireText) + assert.JSONEq(t, text, mustMarshalJSON(t, output)) + default: + assert.JSONEq(t, mustMarshalJSON(t, RepositoryMessageOutput{Message: text}), mustMarshalJSON(t, output)) + assert.JSONEq(t, mustMarshalJSON(t, output), wireText) + } + for _, raw := range []string{`{}`, `[]`, `{"id":false}`, `{"message":1}`} { + var invalid any + require.NoError(t, json.Unmarshal([]byte(raw), &invalid)) + require.Error(t, schema.Validate(invalid), raw) + } +} + +func TestTypedGranularPullRequestWireOutputs(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + for _, reason := range []bool{false, true} { + t.Run("protocol="+protocol+"/reason="+mustMarshalJSON(t, reason), func(t *testing.T) { + var current *granularPRCase + deps := granularPRDeps(t, ¤t, "") + session, schemas := granularPRSession(t, deps, protocol, reason) + for _, tc := range granularPRCases() { + t.Run(tc.name+"/"+tc.text+"/"+mustMarshalJSON(t, tc.args), func(t *testing.T) { + current = &tc + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: granularPRArgs(tc)}) + require.NoError(t, err) + assertGranularPRResult(t, result, schemas[tc.name], tc.text, false) + }) + } + for _, value := range []any{"addressed", "wont-fix", "invalid", "", nil, false} { + tc := granularPRCase{name: "resolve_review_thread", args: map[string]any{"threadID": "T_1", "resolutionReason": value}, text: "review thread resolved successfully"} + current = &tc + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: tc.args}) + require.NoError(t, err) + if reason && (value == nil || value == false) { + typeName := "" + if value == false { + typeName = "bool" + } + assertGranularPRResult(t, result, schemas[tc.name], "parameter resolutionReason is not of type string, is "+typeName, true) + } else { + assertGranularPRResult(t, result, schemas[tc.name], tc.text, false) + } + } + // Legacy handlers sent negative numbers and non-enum strings to GitHub. + for _, tc := range []granularPRCase{ + {"update_pull_request_title", map[string]any{"pullNumber": -1, "title": "Title"}, granularPRReference, `{"title":"Title"}`}, + {"create_pull_request_review", map[string]any{"event": "other"}, "pull request review submitted successfully", `"event":"other"`}, + {"add_pull_request_review_comment", map[string]any{"path": "f", "body": "b", "subjectType": "other", "side": "other", "line": -1}, "pull request review comment successfully added to pending review", `"line":-1`}, + } { + current = &tc + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: granularPRArgs(tc)}) + require.NoError(t, err) + assertGranularPRResult(t, result, schemas[tc.name], tc.text, false) + } + }) + } + } +} + +func TestTypedGranularPullRequestErrorsAndEmptyResults(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + for _, reason := range []bool{false, true} { + t.Run("protocol="+protocol+"/reason="+mustMarshalJSON(t, reason), func(t *testing.T) { + session, schemas := granularPRSession(t, granularPRDeps(t, nil, "error"), protocol, reason) + for _, tc := range granularPRValidationCases() { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: granularPRArgs(tc)}) + require.NoError(t, err) + assertGranularPRResult(t, result, schemas[tc.name], tc.text, true) + } + for _, tc := range granularPRCases() { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: granularPRArgs(tc)}) + require.NoError(t, err) + require.True(t, result.IsError, tc.name) + require.Len(t, result.Content, 1) + assert.Contains(t, result.Content[0].(*mcp.TextContent).Text, "Forbidden") + assert.Nil(t, result.StructuredContent) + } + session, _ = granularPRSession(t, granularPRDeps(t, nil, "mutation_error"), protocol, reason) + for _, tc := range granularPRCases() { + if strings.HasPrefix(tc.text, "{") || tc.name == "remove_pull_request_review_comment_reaction" { + continue + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: granularPRArgs(tc)}) + require.NoError(t, err) + require.True(t, result.IsError, tc.name) + require.Len(t, result.Content, 1) + assert.Contains(t, result.Content[0].(*mcp.TextContent).Text, "Forbidden") + assert.Nil(t, result.StructuredContent) + } + session, schemas = granularPRSession(t, granularPRDeps(t, nil, "empty"), protocol, reason) + for _, tc := range granularPRCases() { + text := tc.text + isError := false + switch tc.name { + case "update_pull_request_title", "update_pull_request_body", "update_pull_request_state", "request_pull_request_reviewers": + text = `{"id":"0","url":""}` + case "add_pull_request_review_comment_reaction": + text = `{"id":"0","url":"https://api.github.com/repos/owner/repo/pulls/comments/42/reactions/0"}` + case "submit_pending_pull_request_review", "delete_pending_pull_request_review", "add_pull_request_review_comment": + text, isError = "No pending review found for the viewer", true + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: granularPRArgs(tc)}) + require.NoError(t, err) + assertGranularPRResult(t, result, schemas[tc.name], text, isError) + } + }) + } + } +} + +func TestGranularPullRequestFeatureVariants(t *testing.T) { + for _, tool := range granularPullRequestTools() { + assert.False(t, tool.IsReadOnly(), tool.Tool.Name) + assert.Equal(t, []string{"repo"}, tool.ScopeAccess.Scopes, tool.Tool.Name) + assert.False(t, tool.FeatureRule.Enabled(func(inventory.FeatureFlag) bool { return false }), tool.Tool.Name) + } + variant := GranularResolveReviewThreadWithResolutionReason(translations.NullTranslationHelper, WithHost(utils.HostTypeGHES)) + require.NotNil(t, variant.Enabled) + enabled, err := variant.Enabled(context.Background()) + require.NoError(t, err) + assert.False(t, enabled) +} + +func TestGranularPullRequestAdvertisedInputConstraints(t *testing.T) { + enumFields := map[string]map[string][]any{ + "update_pull_request_state": {"state": {"open", "closed"}}, + "create_pull_request_review": {"event": {"APPROVE", "REQUEST_CHANGES", "COMMENT"}}, + "submit_pending_pull_request_review": {"event": {"APPROVE", "REQUEST_CHANGES", "COMMENT"}}, + "add_pull_request_review_comment": { + "subjectType": {"FILE", "LINE"}, "side": {"LEFT", "RIGHT"}, "startSide": {"LEFT", "RIGHT"}, + }, + "add_pull_request_review_comment_reaction": {"content": {"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"}}, + } + for _, tool := range granularPullRequestTools() { + schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, tool.Tool.Name) + validation := granularPullRequestValidationSchema(schema) + for _, field := range []string{"pullNumber", "comment_id", "reaction_id"} { + if property := schema.Properties[field]; property != nil { + require.NotNil(t, property.Minimum, tool.Tool.Name+"/"+field) + assert.Equal(t, 1.0, *property.Minimum, tool.Tool.Name+"/"+field) + assert.Nil(t, validation.Properties[field].Minimum) + } + } + for field, values := range enumFields[tool.Tool.Name] { + require.NotNil(t, schema.Properties[field], tool.Tool.Name+"/"+field) + assert.ElementsMatch(t, values, schema.Properties[field].Enum, tool.Tool.Name+"/"+field) + assert.Empty(t, validation.Properties[field].Enum) + } + assert.ElementsMatch(t, schema.Required, validation.Required) + } +} + +func TestTypedInputDescriptionsMatchMain(t *testing.T) { + descriptions := map[string]map[string]string{ + "add_pull_request_review_comment": { + "side": "The side of the diff to comment on (optional)", + "startSide": "The start side of a multi-line comment (optional)", + "subjectType": "The subject type of the comment", + }, + "add_pull_request_review_comment_reaction": { + "content": "The emoji reaction type", + }, + "create_pull_request_review": { + "event": "The review action to perform. If omitted, creates a pending review.", + }, + "submit_pending_pull_request_review": { + "event": "The review action to perform", + }, + "update_pull_request_state": { + "state": "The new state for the pull request", + }, + "find_duplicate": { + "page": "Page number for pagination (min 0). Zero is forwarded for the GitHub API default.", + "perPage": "Results per page for pagination (min 0, max 100). Zero is forwarded for the GitHub API default.", + }, + } + tools := append(granularPullRequestTools(), FindDuplicate(translations.NullTranslationHelper)) + for _, tool := range tools { + for field, description := range descriptions[tool.Tool.Name] { + schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, tool.Tool.Name) + property := schema.Properties[field] + require.NotNil(t, property, tool.Tool.Name+"/"+field) + assert.Equal(t, description, property.Description, tool.Tool.Name+"/"+field) + } + } + + findDuplicate, ok := FindDuplicate(translations.NullTranslationHelper).Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok) + assert.Equal(t, 0.0, *findDuplicate.Properties["page"].Minimum) + assert.Equal(t, 0.0, *findDuplicate.Properties["perPage"].Minimum) + assert.Equal(t, 100.0, *findDuplicate.Properties["perPage"].Maximum) +} + +func TestGranularPullRequestReactionSchema(t *testing.T) { + schema, err := minimalPullRequestCommentReactionSchema().Resolve(nil) + require.NoError(t, err) + require.NoError(t, schema.Validate(map[string]any{"id": "77", "url": "https://api.github.com/reaction"})) + require.NoError(t, schema.Validate(map[string]any{"id": "0", "url": ""})) + for _, raw := range []string{ + `null`, `[]`, `{}`, `{"id":"77"}`, `{"id":77,"content":"other"}`, + `{"id":77,"content":null}`, `{"id":77,"url":"https://api.github.com/reaction"}`, + `{"id":"77","url":null}`, `{"id":"77","url":"https://api.github.com/reaction","content":"heart"}`, + } { + var output any + require.NoError(t, json.Unmarshal([]byte(raw), &output)) + require.Error(t, schema.Validate(output), raw) + } +} + +func TestGranularPullRequestParentBehavior(t *testing.T) { + tools := granularPullRequestTools() + byName := make(map[string]inventory.ServerTool) + for _, tool := range tools[:len(tools)-1] { + byName[tool.Tool.Name] = tool + } + var current *granularPRCase + deps := granularPRDeps(t, ¤t, "") + ctx := ContextWithDeps(context.Background(), deps) + for _, tc := range append(granularPRCases(), granularPRValidationCases()...) { + t.Run(tc.name+"/"+tc.text, func(t *testing.T) { + current = &tc + tool := byName[tc.name] + result, err := tool.Handler(deps)(ctx, &mcp.CallToolRequest{Params: &mcp.CallToolParamsRaw{ + Name: tc.name, Arguments: json.RawMessage(mustMarshalJSON(t, granularPRArgs(tc))), + }}) + require.NoError(t, err) + require.Len(t, result.Content, 1) + assert.Equal(t, tc.text, result.Content[0].(*mcp.TextContent).Text) + }) + } +} + +type granularPRClientErrorDeps struct{ BaseDeps } + +func (granularPRClientErrorDeps) GetClient(context.Context) (*gogithub.Client, error) { + return nil, fmt.Errorf("client unavailable") +} + +func (granularPRClientErrorDeps) GetGQLClient(context.Context) (*githubv4.Client, error) { + return nil, fmt.Errorf("client unavailable") +} + +func TestTypedGranularPullRequestClientErrors(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + for _, reason := range []bool{false, true} { + session, schemas := granularPRSession(t, granularPRClientErrorDeps{}, protocol, reason) + for _, tc := range granularPRCases() { + message := "failed to get GitHub GraphQL client: client unavailable" + if strings.HasPrefix(tc.text, "{") || tc.name == "remove_pull_request_review_comment_reaction" { + message = "failed to get GitHub client: client unavailable" + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: granularPRArgs(tc)}) + require.NoError(t, err) + assertGranularPRResult(t, result, schemas[tc.name], message, true) + } + } + } +} + +func TestTypedGranularPullRequestResolutionReasonPayload(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + for _, reason := range []bool{false, true} { + for _, value := range []string{"addressed", "wont-fix", "invalid", ""} { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Variables struct { + Input struct { + ThreadID string `json:"threadId"` + ResolutionReason *string `json:"resolutionReason"` + } `json:"input"` + } `json:"variables"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + assert.Equal(t, "T_1", request.Variables.Input.ThreadID) + if reason { + require.NotNil(t, request.Variables.Input.ResolutionReason) + assert.Equal(t, value, *request.Variables.Input.ResolutionReason) + } else { + assert.Nil(t, request.Variables.Input.ResolutionReason) + } + _, _ = w.Write([]byte(`{"data":{"resolveReviewThread":{"thread":{"id":"T_1","isResolved":true}}}}`)) + })}} + session, schemas := granularPRSession(t, BaseDeps{GQLClient: githubv4.NewClient(client)}, protocol, reason) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "resolve_review_thread", Arguments: map[string]any{"threadID": "T_1", "resolutionReason": value}, + }) + require.NoError(t, err) + assertGranularPRResult(t, result, schemas["resolve_review_thread"], "review thread resolved successfully", false) + } + } + } +} diff --git a/pkg/github/typed_issue_outputs_test.go b/pkg/github/typed_issue_outputs_test.go new file mode 100644 index 0000000000..698abdf01a --- /dev/null +++ b/pkg/github/typed_issue_outputs_test.go @@ -0,0 +1,418 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func typedIssueDeps(t *testing.T) BaseDeps { + t.Helper() + relatedIssue := &github.Issue{ + ID: new(int64(7)), Number: new(7), Title: new("Blocker"), + State: new("open"), HTMLURL: new("https://github.com/owner/repo/issues/7"), + RepositoryURL: new("https://api.github.com/repos/owner/repo"), + } + subject := &github.Issue{ + Number: new(123), Title: new("Subject"), State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/123"), + } + types := []*github.IssueType{{ID: new(int64(1)), Name: new("Bug"), IsEnabled: new(false)}} + comment := &github.IssueComment{ + ID: new(int64(42)), HTMLURL: new("https://github.com/owner/repo/issues/123#issuecomment-42"), + IssueURL: new("https://api.github.com/repos/owner/repo/issues/123"), + } + handlers := map[string]http.HandlerFunc{ + "GET /orgs/{owner}/issue-types": mockResponse(t, http.StatusOK, types), + "GET /repos/{owner}/{repo}/issue-types": mockResponse(t, http.StatusOK, types), + string(endpointGetIssue): mockResponse(t, http.StatusOK, relatedIssue), + string(endpointAddBlock): mockResponse(t, http.StatusCreated, subject), + string(endpointRemoveBlk): mockResponse(t, http.StatusOK, subject), + string(endpointBlockedBy): mockResponse(t, http.StatusOK, []*github.Issue{relatedIssue}), + string(endpointBlocking): mockResponse(t, http.StatusOK, []*github.Issue{}), + "POST /repos/{owner}/{repo}/issues/{issue_number}/comments": mockResponse(t, http.StatusCreated, comment), + "PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}": mockResponse(t, http.StatusOK, comment), + "GET /repos/{owner}/{repo}/issues/comments/{comment_id}": mockResponse(t, http.StatusOK, comment), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{"name": "repo", "private": false}), + "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions": mockResponse(t, http.StatusCreated, &github.Reaction{ID: new(int64(9))}), + "POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions": mockResponse(t, http.StatusCreated, &github.Reaction{ID: new(int64(9))}), + "POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues": mockResponse(t, http.StatusCreated, &github.SubIssue{ + Number: new(23), Title: new("Child"), State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/23"), + }), + "DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issue": mockResponse(t, http.StatusOK, &github.SubIssue{ + Number: new(23), Title: new("Child"), State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/23"), + }), + "PATCH /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority": mockResponse(t, http.StatusOK, &github.SubIssue{ + Number: new(23), Title: new("Child"), State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/23"), + }), + "GET /repos/{owner}/{repo}/issues/{issue_number}/semantically_similar": func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Has("threshold") { + assert.Equal(t, "page=0&per_page=0&threshold=0", r.URL.RawQuery) + } else { + assert.Empty(t, r.URL.RawQuery, "omitted threshold and pagination must stay omitted") + } + _, _ = w.Write([]byte(`[{"issue":{"number":7,"title":"Candidate","state":"open","html_url":"https://github.com/owner/repo/issues/7"},"score":null,"confidence":"high","likely_duplicate":true}]`)) + }, + } + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Query string `json:"query"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + container := "repository" + if strings.Contains(request.Query, "organization(") { + container = "organization" + } + _, _ = w.Write([]byte(`{"data":{"` + container + `":{"issueFields":{"nodes":[{"__typename":"IssueFieldSingleSelect","id":"IF_1","fullDatabaseId":"99","name":"Priority","description":"Importance","dataType":"SINGLE_SELECT","visibility":"ALL","options":[{"id":"OPT_1","name":"High","description":"","color":"red","priority":0}]}]}}}}`)) + })}} + return BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(handlers)), + GQLClient: githubv4.NewClient(gql), + } +} + +func TestTypedIssueOutputs(t *testing.T) { + translate := translations.NullTranslationHelper + tools := []inventory.ServerTool{ + IssueRead(translate), ListIssueTypes(translate), ListIssueFields(translate), AddIssueComment(translate), + UpdateIssueComment(translate), IssueDependencyRead(translate), IssueDependencyWrite(translate), FindDuplicate(translate), + } + // These tools remain feature-gated in production. Enable their existing + // feature rules in the contract harness rather than clearing the rules. + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return true, nil }).Build() + require.NoError(t, err) + + const commentText = `{"id":"42","url":"https://github.com/owner/repo/issues/123#issuecomment-42"}` + const reactionText = `{"id":"9","url":"https://api.github.com/repos/owner/repo/issues/123/reactions/9"}` + const blockedText = `{"issues":[{"number":7,"title":"Blocker","state":"OPEN","url":"https://github.com/owner/repo/issues/7","repository":"owner/repo"}],"pageInfo":{"hasNextPage":false,"nextPage":0}}` + const dependencyRefs = `"blocked_issue":{"number":123,"title":"Subject","state":"OPEN","url":"https://github.com/owner/repo/issues/123","repository":"owner/repo"},"blocking_issue":{"number":7,"title":"Blocker","state":"OPEN","url":"https://github.com/owner/repo/issues/7","repository":"owner/repo"}` + calls := []struct { + name string + args map[string]any + text string + structured string + }{ + {name: "issue_read", args: map[string]any{"method": "get", "owner": "owner", "repo": "repo", "issue_number": "7.0", "page": "0", "perPage": "0"}, text: `{"number":7,"title":"Blocker","state":"open","html_url":"https://github.com/owner/repo/issues/7","assignees":[]}`, structured: `{"method":"get","issue":{"number":7,"title":"Blocker","state":"open","html_url":"https://github.com/owner/repo/issues/7","assignees":[]}}`}, + {name: "list_issue_types", args: map[string]any{"owner": "owner"}, text: `[{"id":1,"name":"Bug","is_enabled":false}]`, structured: `[{"id":1,"name":"Bug","is_enabled":false}]`}, + {name: "list_issue_types", args: map[string]any{"owner": "owner", "repo": "repo"}, text: `[{"id":1,"name":"Bug","is_enabled":false}]`, structured: `[{"id":1,"name":"Bug","is_enabled":false}]`}, + {name: "list_issue_fields", args: map[string]any{"owner": "owner"}, text: `[{"id":"IF_1","full_database_id":99,"name":"Priority","description":"Importance","data_type":"SINGLE_SELECT","visibility":"ALL","options":[{"id":"OPT_1","name":"High","color":"red","priority":0}]}]`, structured: `[{"id":"IF_1","full_database_id":99,"name":"Priority","description":"Importance","data_type":"SINGLE_SELECT","visibility":"ALL","options":[{"id":"OPT_1","name":"High","color":"red","priority":0}]}]`}, + {name: "list_issue_fields", args: map[string]any{"owner": "owner", "repo": "repo"}, text: `[{"id":"IF_1","full_database_id":99,"name":"Priority","description":"Importance","data_type":"SINGLE_SELECT","visibility":"ALL","options":[{"id":"OPT_1","name":"High","color":"red","priority":0}]}]`, structured: `[{"id":"IF_1","full_database_id":99,"name":"Priority","description":"Importance","data_type":"SINGLE_SELECT","visibility":"ALL","options":[{"id":"OPT_1","name":"High","color":"red","priority":0}]}]`}, + {name: "add_issue_comment", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123.0", "body": "Hello"}, text: commentText, structured: `{"id":"42","html_url":"https://github.com/owner/repo/issues/123#issuecomment-42"}`}, + {name: "add_issue_comment", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123", "reaction": "heart"}, text: reactionText, structured: `{"id":"9"}`}, + {name: "add_issue_comment", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123", "body": "Hello", "reaction": "heart"}, text: `{"comment":` + commentText + `,"reaction":` + reactionText + `}`, structured: `{"comment":{"id":"42","html_url":"https://github.com/owner/repo/issues/123#issuecomment-42"},"reaction":{"id":"9"}}`}, + {name: "add_issue_comment", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123", "comment_id": "42.0", "reaction": "heart"}, text: `{"id":"9","url":"https://api.github.com/repos/owner/repo/issues/comments/42/reactions/9"}`, structured: `{"id":"9"}`}, + {name: "update_issue_comment", args: map[string]any{"owner": "owner", "repo": "repo", "comment_id": "42.0", "body": "Changed"}, text: commentText, structured: `{"id":"42","html_url":"https://github.com/owner/repo/issues/123#issuecomment-42"}`}, + {name: "issue_dependency_read", args: map[string]any{"method": "get_blocked_by", "owner": "owner", "repo": "repo", "issue_number": "123", "page": "0", "perPage": "0"}, text: blockedText, structured: `{"issues":[{"number":7,"title":"Blocker","state":"OPEN","html_url":"https://github.com/owner/repo/issues/7","repository":"owner/repo"}],"pageInfo":{"hasNextPage":false,"nextPage":0}}`}, + {name: "issue_dependency_read", args: map[string]any{"method": "get_blocking", "owner": "owner", "repo": "repo", "issue_number": "123"}, text: `{"issues":[],"pageInfo":{"hasNextPage":false,"nextPage":0}}`, structured: `{"issues":[],"pageInfo":{"hasNextPage":false,"nextPage":0}}`}, + {name: "issue_dependency_write", args: map[string]any{"method": "ADD", "type": "BLOCKED_BY", "owner": "owner", "repo": "repo", "issue_number": "123", "related_issue_number": "7"}, text: `{` + dependencyRefs + `,"message":"dependency added"}`, structured: `{"blocked_issue":{"number":123,"title":"Subject","state":"OPEN","html_url":"https://github.com/owner/repo/issues/123","repository":"owner/repo"},"blocking_issue":{"number":7,"title":"Blocker","state":"OPEN","html_url":"https://github.com/owner/repo/issues/7","repository":"owner/repo"},"message":"dependency added"}`}, + {name: "issue_dependency_write", args: map[string]any{"method": "REMOVE", "type": "BLOCKED_BY", "owner": "owner", "repo": "repo", "issue_number": "123", "related_issue_number": "7"}, text: `{` + dependencyRefs + `,"message":"dependency removed"}`, structured: `{"blocked_issue":{"number":123,"title":"Subject","state":"OPEN","html_url":"https://github.com/owner/repo/issues/123","repository":"owner/repo"},"blocking_issue":{"number":7,"title":"Blocker","state":"OPEN","html_url":"https://github.com/owner/repo/issues/7","repository":"owner/repo"},"message":"dependency removed"}`}, + {name: "find_duplicate", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123", "confidence_threshold": 0, "page": "0", "perPage": "0"}, text: `[{"issue":{"number":7,"title":"Candidate","state":"open","url":"https://github.com/owner/repo/issues/7"},"score":null,"confidence":"high","likely_duplicate":true}]`, structured: `[{"issue":{"number":7,"title":"Candidate","state":"open","html_url":"https://github.com/owner/repo/issues/7"},"score":null,"confidence":"high","likely_duplicate":true}]`}, + {name: "find_duplicate", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123"}, text: `[{"issue":{"number":7,"title":"Candidate","state":"open","url":"https://github.com/owner/repo/issues/7"},"score":null,"confidence":"high","likely_duplicate":true}]`, structured: `[{"issue":{"number":7,"title":"Candidate","state":"open","html_url":"https://github.com/owner/repo/issues/7"},"score":null,"confidence":"high","likely_duplicate":true}]`}, + } + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip, ""} { + name := protocol + if name == "" { + name = "unknown" + } + t.Run(name, func(t *testing.T) { + deps := typedIssueDeps(t) + server := mcp.NewServer(&mcp.Implementation{Name: "typed-issue-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + if protocol == "" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch request := req.(type) { + case *mcp.ListToolsRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + case *mcp.CallToolRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + } + return next(ctx, method, req) + } + }) + } + version := protocol + if version == "" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, tool.Name) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + schemas[tool.Name] = resolved + if tool.Name == "add_issue_comment" { + for _, invalid := range []string{ + `{}`, + `{"id":"1","html_url":"url","reaction":{"id":"2"}}`, + `{"comment":{"id":"1","html_url":"url"}}`, + `{"comment":{"id":"1","html_url":"url"},"reaction":{"id":"2","html_url":"url"}}`, + } { + var output any + require.NoError(t, json.Unmarshal([]byte(invalid), &output)) + require.Error(t, resolved.Validate(output), "partial comment unions must be rejected") + } + } + } + + for _, call := range calls { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: call.name, Arguments: call.args}) + require.NoError(t, err, call.name) + require.False(t, result.IsError, "%s: %s", call.name, result) + require.Len(t, result.Content, 1, call.name) + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Equal(t, call.text, getTextResult(t, result).Text, call.name) + assert.Nil(t, result.StructuredContent, call.name) + continue + } + require.NotNil(t, result.StructuredContent, call.name) + structured := mustMarshalJSON(t, result.StructuredContent) + assert.JSONEq(t, call.structured, structured) + if call.name == "issue_read" || call.name == "add_issue_comment" { + assert.JSONEq(t, structured, getTextResult(t, result).Text, call.name) + } + var output any + require.NoError(t, json.Unmarshal([]byte(structured), &output)) + require.NoError(t, schemas[call.name].Validate(output), call.name) + } + errors := []struct { + name string + args map[string]any + text string + }{ + {"list_issue_types", map[string]any{}, "missing required parameter: owner"}, + {"list_issue_fields", map[string]any{"owner": "owner", "repo": nil}, "parameter repo is not of type string"}, + {"add_issue_comment", map[string]any{"owner": "owner", "repo": "repo", "issue_number": 123}, "at least one of body or reaction is required"}, + {"add_issue_comment", map[string]any{"owner": "owner", "repo": "repo", "issue_number": 123, "body": "Hello", "comment_id": 42}, "comment_id cannot be combined with body"}, + {"update_issue_comment", map[string]any{"owner": "owner", "repo": "repo", "comment_id": "1.5", "body": "Hello"}, "non-integer numeric value"}, + {"issue_dependency_read", map[string]any{"method": "GET_BLOCKING", "owner": "owner", "repo": "repo", "issue_number": 123}, "get_blocking"}, + {"issue_dependency_write", map[string]any{"method": "add", "type": "blocked_by", "owner": "owner", "repo": "repo", "issue_number": 123, "related_issue_number": 123}, "an issue cannot block or depend on itself"}, + {"find_duplicate", map[string]any{"owner": "owner", "repo": "repo", "issue_number": "1.5"}, "not a valid number"}, + {"find_duplicate", map[string]any{"owner": "owner", "repo": "repo", "issue_number": 123, "confidence_threshold": nil}, "parameter confidence_threshold is not of type float64"}, + } + for _, call := range errors { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: call.name, Arguments: call.args}) + require.NoError(t, err, call.name) + require.True(t, result.IsError, call.name) + assert.Contains(t, getErrorResult(t, result).Text, call.text) + assert.Nil(t, result.StructuredContent, call.name) + } + }) + } +} + +func TestTypedIssueNullOutput(t *testing.T) { + tool := ListIssueTypes(translations.NullTranslationHelper) + inv, err := inventory.NewBuilder().SetTools([]inventory.ServerTool{tool}).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{owner}/issue-types": func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("null")) + }, + }))} + + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocol, func(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "typed-issue-null", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + session := connectCommentVisibilityClient(t, server, protocol) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + require.NotNil(t, list.Tools[0].OutputSchema) + } else { + assert.Nil(t, list.Tools[0].OutputSchema) + } + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "list_issue_types", Arguments: map[string]any{"owner": "owner"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, "null", getTextResult(t, result).Text) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, result.StructuredContent, "the SDK omits structuredContent when the typed output is null") + } else { + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestIssuePaginationValidationSchemaKeepsAdvertisedSchema(t *testing.T) { + advertised := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "page": {Type: "number", Description: "Page number (min 1)", Minimum: new(1.0), Default: json.RawMessage("1")}, + "perPage": {Type: "number", Description: "Items per page (min 1, max 100)", Minimum: new(1.0), Maximum: new(100.0), Default: json.RawMessage("30")}, + }, + } + advertisedBefore := mustMarshalJSON(t, advertised) + + validation := issuePaginationValidationSchema(advertised) + require.Equal(t, advertisedBefore, mustMarshalJSON(t, advertised), "runtime validation must not mutate the advertised schema") + assert.Equal(t, "Page number (min 1)", validation.Properties["page"].Description) + assert.Equal(t, "Items per page (min 1, max 100)", validation.Properties["perPage"].Description) + assert.Equal(t, 1.0, *advertised.Properties["page"].Minimum) + assert.Equal(t, 1.0, *advertised.Properties["perPage"].Minimum) + assert.Equal(t, 0.0, *validation.Properties["page"].Minimum) + assert.Equal(t, 0.0, *validation.Properties["perPage"].Minimum) + assert.Equal(t, 100.0, *validation.Properties["perPage"].Maximum) + assert.Nil(t, validation.Properties["page"].Default, "validation must not inject defaults absent from the original call") + assert.Nil(t, validation.Properties["perPage"].Default, "validation must not inject defaults absent from the original call") + resolved, err := validation.Resolve(nil) + require.NoError(t, err) + require.NoError(t, resolved.Validate(map[string]any{"page": float64(0), "perPage": float64(0)})) + assert.Error(t, resolved.Validate(map[string]any{"page": float64(-1)})) +} + +func TestTypedIssueAPIErrors(t *testing.T) { + translate := translations.NullTranslationHelper + cases := []struct { + tool inventory.ServerTool + args map[string]any + text string + }{ + {ListIssueTypes(translate), map[string]any{"owner": "owner"}, "failed to list issue types"}, + {ListIssueFields(translate), map[string]any{"owner": "owner"}, "failed to list issue fields"}, + {AddIssueComment(translate), map[string]any{"owner": "owner", "repo": "repo", "issue_number": 123, "body": "Hello"}, "failed to create comment"}, + {UpdateIssueComment(translate), map[string]any{"owner": "owner", "repo": "repo", "comment_id": 42, "body": "Hello"}, "failed to update issue comment"}, + {IssueDependencyRead(translate), map[string]any{"method": "get_blocked_by", "owner": "owner", "repo": "repo", "issue_number": 123}, "failed to list blocked-by issues"}, + {IssueDependencyWrite(translate), map[string]any{"method": "add", "type": "blocked_by", "owner": "owner", "repo": "repo", "issue_number": 123, "related_issue_number": 7}, "failed to resolve blocking issue"}, + {FindDuplicate(translate), map[string]any{"owner": "owner", "repo": "repo", "issue_number": 123}, "failed to find duplicate issues"}, + } + for _, tc := range cases { + t.Run(tc.tool.Tool.Name, func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client), GQLClient: githubv4.NewClient(client)} + inv, err := inventory.NewBuilder().SetTools([]inventory.ServerTool{tc.tool}).WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return true, nil }).Build() + require.NoError(t, err) + var legacyText string + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + server := mcp.NewServer(&mcp.Implementation{Name: "typed-issue-errors", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + session := connectCommentVisibilityClient(t, server, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + text := getErrorResult(t, result).Text + assert.Contains(t, text, tc.text) + assert.Nil(t, result.StructuredContent) + if protocol == "2025-11-25" { + legacyText = text + } else { + assert.Equal(t, legacyText, text, "API errors must be byte-exact across protocols") + } + } + }) + } +} + +func TestTypedSubIssueWriteProtocols(t *testing.T) { + translate := translations.NullTranslationHelper + tool := SubIssueWrite(translate) + inv, err := inventory.NewBuilder().SetTools([]inventory.ServerTool{tool}).WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return false, nil }).Build() + require.NoError(t, err) + + expectedIssue := &github.SubIssue{ + Number: new(23), Title: new("Child"), State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/23"), + } + legacy, err := json.Marshal(expectedIssue) + require.NoError(t, err) + subIssue, err := subIssueOutput(expectedIssue) + require.NoError(t, err) + structured, err := json.Marshal(SubIssueWriteOutput{Method: "add", Issue: subIssue}) + require.NoError(t, err) + + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocol, func(t *testing.T) { + deps := typedIssueDeps(t) + server := mcp.NewServer(&mcp.Implementation{Name: "typed-sub-issue-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + session := connectCommentVisibilityClient(t, server, protocol) + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + require.NotNil(t, list.Tools[0].OutputSchema) + var outputSchema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, list.Tools[0].OutputSchema)), &outputSchema)) + resolvedSchema, err := outputSchema.Resolve(nil) + require.NoError(t, err) + var output any + require.NoError(t, json.Unmarshal(structured, &output)) + require.NoError(t, resolvedSchema.Validate(output)) + } else { + assert.Nil(t, list.Tools[0].OutputSchema) + } + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "sub_issue_write", + Arguments: map[string]any{ + "method": "ADD", "owner": "owner", "repo": "repo", + "issue_number": "123.0", "sub_issue_id": "23.0", "replace_parent": true, + }, + }) + require.NoError(t, err) + require.False(t, result.IsError) + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Equal(t, string(legacy), getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + return + } + require.NotNil(t, result.StructuredContent) + assert.JSONEq(t, string(structured), mustMarshalJSON(t, result.StructuredContent)) + assert.JSONEq(t, string(structured), getTextResult(t, result).Text) + + invalid, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "sub_issue_write", + Arguments: map[string]any{ + "method": "add", "owner": "owner", "repo": "repo", + "issue_number": 123, "sub_issue_id": 23, "replace_parent": nil, + }, + }) + require.NoError(t, err) + require.True(t, invalid.IsError) + assert.Contains(t, getErrorResult(t, invalid).Text, "parameter replace_parent is not of type bool") + assert.Nil(t, invalid.StructuredContent) + }) + } +} diff --git a/pkg/github/typed_projects_outputs_test.go b/pkg/github/typed_projects_outputs_test.go new file mode 100644 index 0000000000..34cbc9acf2 --- /dev/null +++ b/pkg/github/typed_projects_outputs_test.go @@ -0,0 +1,702 @@ +package github + +import ( + "context" + "encoding/json" + "maps" + "net/http" + "testing" + + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func projectsTypedSession(t *testing.T, deps ToolDependencies, protocol string) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + tr := translations.NullTranslationHelper + tools := []inventory.ServerTool{ProjectsList(tr), ProjectsGet(tr), ProjectsWrite(tr)} + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "projects", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + if protocol == "" || protocol == "unknown" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch req := req.(type) { + case *mcp.ListToolsRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + case *mcp.CallToolRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + } + return next(ctx, method, req) + } + }) + } + version := protocol + if version == "" || version == "unknown" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 3) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + definition := map[string]inventory.ServerTool{ + "projects_list": tools[0], "projects_get": tools[1], "projects_write": tools[2], + }[tool.Name] + inventory.AnnotateHeaderParams(&definition.Tool) + assert.JSONEq(t, mustMarshalJSON(t, definition.Tool.InputSchema), mustMarshalJSON(t, tool.InputSchema)) + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, tool.Name) + assert.JSONEq(t, mustMarshalJSON(t, definition.Tool.OutputSchema), mustMarshalJSON(t, tool.OutputSchema)) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + schemas[tool.Name] = resolved + } + return session, schemas +} + +type projectWireStep struct { + method string + path string + query string + body string + status int + header http.Header +} + +type projectWireCase struct { + tool string + method string + args map[string]any + steps []projectWireStep + text string +} + +const ( + projectWireProject = `{"id":1,"node_id":"P","title":"Roadmap","public":true,"number":2}` + projectWireProjectText = `{"id":1,"node_id":"P","title":"Roadmap","description":"","public":true,"closed_at":"0001-01-01T00:00:00Z","created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z","deleted_at":"0001-01-01T00:00:00Z","number":2,"short_description":""}` + projectWireField = `{"id":3,"node_id":"F","name":"Status","data_type":"SINGLE_SELECT","options":[{"id":"o","name":{"raw":"Ready","html":"Ready"},"color":"GREEN"}]}` + projectWireFieldText = `{"id":3,"node_id":"F","name":"Status","data_type":"SINGLE_SELECT","options":[{"id":"o","color":"GREEN","name":{"html":"Ready","raw":"Ready"}}]}` + projectWireItem = `{"id":4,"node_id":"I","content_type":"Issue","fields":[{"id":3,"name":"Custom","data_type":"CUSTOM","value":{"custom":{"a":7},"flag":true}}]}` + projectWireItemText = `{"id":4,"node_id":"I","content_type":"Issue","fields":[{"id":3,"name":"Custom","data_type":"CUSTOM","value":{"custom":{"a":7},"flag":true}}]}` + projectWireView = `{"id":"V","number":1,"name":"Board","layout":"BOARD_LAYOUT","filter":null,"configuration":{"visibleFields":{"nodes":[]}}}` + projectWireViewText = `{"id":"V","number":1,"name":"Board","layout":"board","filter":"","visible_fields":[]}` + projectWireStatus = `{"id":"S","body":"Update","status":"ON_TRACK","createdAt":"2026-01-01T00:00:00Z","startDate":null,"targetDate":null,"creator":{"login":"octocat"}}` + projectWireStatusText = `{"id":"S","body":"Update","status":"ON_TRACK","created_at":"2026-01-01T00:00:00Z","creator":{"login":"octocat"}}` + projectWireResolve = `{"data":{"organization":{"projectV2":{"id":"P"}}}}` + projectWireParent = `{"data":{"node":{"id":"V","layout":"BOARD_LAYOUT","project":{"id":"P"}}}}` +) + +func projectWireCases() []projectWireCase { + rest := func(method, path, query, body string) projectWireStep { + return projectWireStep{method: method, path: path, query: query, body: body} + } + gql := func(body string) projectWireStep { + return rest("POST", "/graphql", "", body) + } + return []projectWireCase{ + {tool: "projects_list", method: projectsMethodListProjects, args: map[string]any{"perPage": "2e0", "after": "next", "query": "is:open", "project_number": false}, + steps: []projectWireStep{rest("GET", "/orgs/o/projectsV2", "after=next&per_page=2&q=is%3Aopen", "["+projectWireProject+"]")}, + text: `{"pageInfo":{"hasNextPage":false,"hasPreviousPage":false},"projects":[` + projectWireProjectText[:len(projectWireProjectText)-1] + `,"owner_type":"org"}]}`}, + {tool: "projects_list", method: projectsMethodListProjectFields, args: map[string]any{"perPage": 0}, + steps: []projectWireStep{rest("GET", "/orgs/o/projectsV2/2/fields", "per_page=50", "["+projectWireField+"]")}, + text: `{"fields":[` + projectWireFieldText + `],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`}, + {tool: "projects_list", method: projectsMethodListProjectItems, args: map[string]any{"fields": []any{"3", "5"}, "before": "prev"}, + steps: []projectWireStep{rest("GET", "/orgs/o/projectsV2/2/items", "before=prev&fields=3%2C5&per_page=50", "["+projectWireItem+"]")}, + text: `{"items":[` + projectWireItemText + `],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`}, + {tool: "projects_list", method: projectsMethodListProjectStatusUpdates, steps: []projectWireStep{gql(`{"data":{"organization":{"projectV2":{"public":true,"statusUpdates":{"nodes":[` + projectWireStatus + `],"pageInfo":{"hasNextPage":true,"hasPreviousPage":false,"endCursor":"next","startCursor":""}}}}}}`)}, + text: `{"pageInfo":{"hasNextPage":true,"hasPreviousPage":false,"nextCursor":"next","prevCursor":""},"statusUpdates":[` + projectWireStatusText + `]}`}, + {tool: "projects_list", method: projectsMethodListProjectViews, steps: []projectWireStep{gql(`{"data":{"organization":{"projectV2":{"id":"P","public":true,"views":{"nodes":[` + projectWireView + `],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"endCursor":"","startCursor":""}}}}}}`)}, + text: `{"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"nextCursor":"","prevCursor":""},"views":[` + projectWireViewText + `]}`}, + {tool: "projects_get", method: projectsMethodGetProject, steps: []projectWireStep{rest("GET", "/orgs/o/projectsV2/2", "", projectWireProject)}, text: projectWireProjectText}, + {tool: "projects_get", method: projectsMethodGetProjectField, args: map[string]any{"field_id": "3e0"}, steps: []projectWireStep{rest("GET", "/orgs/o/projectsV2/2/fields/3", "", projectWireField)}, text: projectWireFieldText}, + {tool: "projects_get", method: projectsMethodGetProjectItem, args: map[string]any{"item_id": "4", "fields": []any{"3"}}, + steps: []projectWireStep{rest("GET", "/orgs/o/projectsV2/2/items/4", "fields=3", projectWireItem)}, text: projectWireItemText}, + {tool: "projects_get", method: projectsMethodGetProjectStatusUpdate, args: map[string]any{"status_update_id": "S", "owner": false, "project_number": nil}, + steps: []projectWireStep{gql(`{"data":{"node":` + projectWireStatus[:len(projectWireStatus)-1] + `,"project":{"public":true}}}}`)}, text: projectWireStatusText}, + {tool: "projects_get", method: projectsMethodGetProjectView, args: map[string]any{"view_id": "V"}, + steps: []projectWireStep{gql(`{"data":{"node":` + projectWireView[:len(projectWireView)-1] + `,"project":{"public":true}}}}`)}, text: projectWireViewText}, + {tool: "projects_write", method: projectsMethodAddProjectItem, args: map[string]any{"item_type": "issue", "item_owner": "o", "item_repo": "r", "issue_number": "7"}, + steps: []projectWireStep{gql(`{"data":{"repository":{"issue":{"id":"ISSUE"}}}}`), gql(projectWireResolve), gql(`{"data":{"addProjectV2ItemById":{"item":{"id":"I","fullDatabaseId":"4"}}}}`)}, + text: `{"full_database_id":"4","id":"I","item_id":4,"message":"Successfully added issue o/r#7 to project o/2"}`}, + {tool: "projects_write", method: projectsMethodDeleteProjectItem, args: map[string]any{"item_id": "4"}, + steps: []projectWireStep{rest("DELETE", "/orgs/o/projectsV2/2/items/4", "", "")}, text: "project item successfully deleted"}, + {tool: "projects_write", method: projectsMethodUpdateProjectItem, args: map[string]any{"item_id": "4", "updated_field": map[string]any{"id": 3, "value": nil}}, + steps: []projectWireStep{rest("PATCH", "/orgs/o/projectsV2/2/items/4", "", projectWireItem)}, text: projectWireItemText}, + {tool: "projects_write", method: projectsMethodUpdateProjectItem, args: map[string]any{"item_id": "4", "updated_field": map[string]any{"name": "Customer", "value": "Acme"}}, + steps: []projectWireStep{ + gql(`{"data":{"organization":{"projectV2":{"fields":{"nodes":[{"id":"F","databaseId":3,"name":"Customer","dataType":"TEXT"}],"pageInfo":{"hasNextPage":false}}}}}}`), + gql(`{"data":{"organization":{"projectV2":{"fields":{"nodes":[{"__typename":"ProjectV2Field","databaseId":3,"isIssueField":true,"issueField":{"id":"IF"}}],"pageInfo":{"hasNextPage":false}}}}}}`), + rest("GET", "/orgs/o/projectsV2/2/items/4", "", `{"id":4,"content_type":"Issue","content":{"node_id":"ISSUE"}}`), + gql(`{"data":{"setIssueFieldValue":{"issue":{"id":"ISSUE","url":"https://github.com/o/r/issues/7"}}}}`), + }, text: `{"id":"ISSUE","url":"https://github.com/o/r/issues/7"}`}, + {tool: "projects_write", method: projectsMethodUpdateProjectItems, args: map[string]any{"items": []any{map[string]any{"node_id": "I"}, map[string]any{"node_id": "I"}}, "updated_field": map[string]any{"name": "Notes", "value": "hello"}}, + steps: []projectWireStep{ + gql(projectWireResolve), + gql(`{"data":{"organization":{"projectV2":{"fields":{"nodes":[{"id":"F","databaseId":3,"name":"Notes","dataType":"TEXT"}],"pageInfo":{"hasNextPage":false}}}}}}`), + gql(`{"data":{"item0":{"projectV2Item":{"id":"I","fullDatabaseId":"4"}}}}`), + }, text: `{"failed":1,"results":[{"index":0,"status":"succeeded","item":{"node_id":"I","full_database_id":"4"},"ref":{"node_id":"I"}},{"index":1,"status":"failed","error":{"code":"duplicate_target","message":"items[1] targets the same project item as items[0]; each item may only be written once per call"},"ref":{"node_id":"I"}}],"succeeded":1,"total":2,"unknown":0}`}, + {tool: "projects_write", method: projectsMethodCreateProjectStatusUpdate, args: map[string]any{"body": "Update", "status": "ON_TRACK"}, + steps: []projectWireStep{gql(projectWireResolve), gql(`{"data":{"createProjectV2StatusUpdate":{"statusUpdate":` + projectWireStatus + `}}}`)}, text: projectWireStatusText}, + {tool: "projects_write", method: projectsMethodCreateProjectView, args: map[string]any{"name": "Board", "layout": "board", "filter": nil, "visible_fields": []any{}}, + steps: []projectWireStep{gql(projectWireResolve), gql(`{"data":{"createProjectV2View":{"projectV2View":` + projectWireView + `}}}`)}, text: projectWireViewText}, + {tool: "projects_write", method: projectsMethodUpdateProjectView, args: map[string]any{"view_id": "V", "filter": nil, "visible_fields": []any{}}, + steps: []projectWireStep{gql(projectWireResolve), gql(projectWireParent), gql(`{"data":{"updateProjectV2View":{"projectV2View":` + projectWireView + `}}}`)}, text: projectWireViewText}, + {tool: "projects_write", method: projectsMethodDeleteProjectView, args: map[string]any{"view_id": "V"}, + steps: []projectWireStep{gql(projectWireResolve), gql(projectWireParent), gql(`{"data":{"deleteProjectV2View":{"projectV2View":{"id":"V"}}}}`)}, text: `{"deleted_view_id":"V"}`}, + {tool: "projects_write", method: projectsMethodCreateProject, args: map[string]any{"title": "Roadmap", "project_number": false}, + steps: []projectWireStep{gql(`{"data":{"organization":{"id":"O"}}}`), gql(`{"data":{"createProjectV2":{"projectV2":{"id":"P","number":2,"title":"Roadmap","url":"https://github.com/orgs/o/projects/2"}}}}`)}, + text: `{"id":"P","number":2,"title":"Roadmap","url":"https://github.com/orgs/o/projects/2"}`}, + {tool: "projects_write", method: projectsMethodCreateIterationField, args: map[string]any{"field_name": "Sprint", "start_date": "2026-01-01", "iteration_duration": "7"}, + steps: []projectWireStep{gql(projectWireResolve), gql(`{"data":{"createProjectV2Field":{"projectV2Field":{"id":"F","name":"Sprint"}}}}`), gql(`{"data":{"updateProjectV2Field":{"projectV2Field":{"id":"F","name":"Sprint","configuration":{"iterations":[{"id":"ITER","title":"Week","startDate":"2026-01-01","duration":7}]}}}}}`)}, + text: `{"configuration":{"iterations":[{"duration":7,"id":"ITER","start_date":"2026-01-01","title":"Week"}]},"id":"F","name":"Sprint"}`}, + } + +} + +func TestTypedProjectsOutputSchemaContracts(t *testing.T) { + for _, tc := range []struct { + name string + schema *jsonschema.Schema + bad string + }{ + {"list", projectsListOutputSchema(), `{"method":"list_project_items","items":{"items":[{"id":"bad"}],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}}`}, + {"get", projectsGetOutputSchema(), `{"method":"get_project_item","item":{"id":4,"fields":[{"name":5}]}}`}, + {"write", projectsWriteOutputSchema(), `{"method":"delete_project_view","deleted_view":{"deleted_view_id":123}}`}, + {"view", projectOutputSchema[MinimalProjectView](), `{"id":"V","number":1,"name":"Board","layout":"board","filter":"","visible_fields":null}`}, + {"optional", projectOutputSchema[MinimalProject](), `{"id":null}`}, + {"batch", projectOutputSchema[ProjectBatchOutput](), `{"total":1,"succeeded":1,"failed":0,"unknown":0,"results":[{"index":0,"status":"succeeded","item":{"node_id":false}}]}`}, + } { + t.Run(tc.name, func(t *testing.T) { + schema, err := tc.schema.Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(tc.bad), &value)) + assert.Error(t, schema.Validate(value)) + }) + } + for _, raw := range []string{`{"id":null,"message":"added"}`, `{"id":"I","message":"added","item_id":0}`, `{"id":"I","message":"added","full_database_id":"not-numeric"}`} { + out, err := decodeProjectsWriteOutput(projectsMethodAddProjectItem, []byte(raw)) + require.NoError(t, err) + assert.JSONEq(t, `{"method":"add_project_item","added":`+raw+`}`, mustMarshalJSON(t, out)) + } + raw := `{"total":2,"succeeded":1,"failed":1,"unknown":0,"results":[{"index":0,"status":"succeeded","item":{"node_id":"I"}},{"index":1,"status":"failed","ref":{"unexpected":[1,true,null]},"error":{"code":"ambiguous","message":"many","candidates":[{"id":"I","name":"A"}]}}]}` + out, err := decodeProjectsWriteOutput(projectsMethodUpdateProjectItems, []byte(raw)) + require.NoError(t, err) + assert.JSONEq(t, `{"method":"update_project_items","batch":`+raw+`}`, mustMarshalJSON(t, out)) + schema, err := projectsWriteOutputSchema().Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, out)), &value)) + require.NoError(t, schema.Validate(value)) +} + +func projectWireDeps(t *testing.T, tc projectWireCase) BaseDeps { + t.Helper() + index := 0 + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Less(t, index, len(tc.steps), "unexpected request %s %s", r.Method, r.URL) + step := tc.steps[index] + index++ + assert.Equal(t, step.method, r.Method) + assert.Equal(t, step.path, r.URL.Path) + assert.Equal(t, step.query, r.URL.RawQuery) + for name, values := range step.header { + for _, value := range values { + w.Header().Add(name, value) + } + } + if step.status != 0 { + w.WriteHeader(step.status) + } + if step.method == "DELETE" { + if step.status == 0 { + w.WriteHeader(http.StatusNoContent) + } + } + _, _ = w.Write([]byte(step.body)) + })}} + t.Cleanup(func() { assert.Equal(t, len(tc.steps), index, tc.method) }) + return BaseDeps{Client: mustNewGHClient(t, client), GQLClient: githubv4.NewEnterpriseClient("https://api.github.com/graphql", client)} +} + +func projectWireArgs(tc projectWireCase) map[string]any { + args := map[string]any{"method": tc.method, "owner": "o", "owner_type": "org", "project_number": "2e0"} + maps.Copy(args, tc.args) + return args +} + +func TestTypedProjectsWireOutputs(t *testing.T) { + for _, protocol := range typedGitGistProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + for _, tc := range projectWireCases() { + t.Run(tc.method, func(t *testing.T) { + session, schemas := projectsTypedSession(t, projectWireDeps(t, tc), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool, Arguments: projectWireArgs(tc)}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + if schema := schemas[tc.tool]; schema != nil { + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + assert.JSONEq(t, projectWireStructured(t, tc), mustMarshalJSON(t, output)) + assert.Equal(t, mustMarshalJSON(t, output), getTextResult(t, result).Text) + } else { + assert.Equal(t, tc.text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } + }) + } + }) + } +} + +func projectWireStructured(t *testing.T, tc projectWireCase) string { + t.Helper() + fields := map[string]string{ + projectsMethodListProjects: "projects", projectsMethodListProjectFields: "fields", + projectsMethodListProjectItems: "items", projectsMethodListProjectStatusUpdates: "status_updates", + projectsMethodListProjectViews: "views", projectsMethodGetProject: "project", + projectsMethodGetProjectField: "field", projectsMethodGetProjectItem: "item", + projectsMethodGetProjectStatusUpdate: "status_update", projectsMethodGetProjectView: "view", + projectsMethodAddProjectItem: "added", projectsMethodUpdateProjectItem: "item", + projectsMethodUpdateProjectItems: "batch", projectsMethodDeleteProjectItem: "deleted_item", + projectsMethodCreateProjectStatusUpdate: "status_update", projectsMethodCreateProjectView: "view", + projectsMethodUpdateProjectView: "view", projectsMethodDeleteProjectView: "deleted_view", + projectsMethodCreateProject: "project", projectsMethodCreateIterationField: "iteration_field", + } + var payload map[string]any + if tc.method == projectsMethodDeleteProjectItem { + payload = map[string]any{"message": tc.text} + } else { + require.NoError(t, json.Unmarshal([]byte(tc.text), &payload)) + } + if url, ok := payload["url"]; ok { + payload["html_url"] = url + delete(payload, "url") + if tc.method == projectsMethodUpdateProjectItem { + fields[tc.method] = "issue_fields" + } + } + return mustMarshalJSON(t, map[string]any{"method": tc.method, fields[tc.method]: payload}) +} + +func TestTypedProjectsObjectRoots(t *testing.T) { + for name, schema := range map[string]*jsonschema.Schema{ + "projects_list": projectsListOutputSchema(), + "projects_get": projectsGetOutputSchema(), + "projects_write": projectsWriteOutputSchema(), + } { + t.Run(name, func(t *testing.T) { + assert.Equal(t, "object", schema.Type) + assert.Empty(t, schema.AnyOf) + assert.Contains(t, schema.Required, "method") + assert.NotEmpty(t, schema.Properties["method"].Enum) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + assert.Error(t, resolved.Validate(nil)) + assert.Error(t, resolved.Validate([]any{})) + assert.Error(t, resolved.Validate(map[string]any{})) + assert.Error(t, resolved.Validate(map[string]any{"method": "unknown"})) + require.NoError(t, toolsnaps.Test(name, map[string]inventory.ServerTool{ + "projects_list": ProjectsList(translations.NullTranslationHelper), + "projects_get": ProjectsGet(translations.NullTranslationHelper), + "projects_write": ProjectsWrite(translations.NullTranslationHelper), + }[name].Tool)) + }) + } +} + +func TestTypedProjectsFieldProjection(t *testing.T) { + raw := `{"id":3,"node_id":"F","name":"Status","data_type":"SINGLE_SELECT","project_url":"https://api.github.com/orgs/o/projectsV2/2","options":[{"id":"o","name":{"raw":"Ready","html":"Ready"},"color":"GREEN"}],"configuration":{"duration":7,"start_day":1,"iterations":[{"id":"iter","title":{"raw":"Sprint","html":"Sprint"},"duration":7,"start_date":"2026-01-01"}]}}` + out, err := decodeProjectsGetOutput(projectsMethodGetProjectField, []byte(raw)) + require.NoError(t, err) + assert.NotContains(t, mustMarshalJSON(t, out), "project_url") + assert.Equal(t, "Ready", *out.Field.Options[0].Name.Raw) + assert.Equal(t, "Sprint", *out.Field.Configuration.Iterations[0].Title.Raw) + schema, err := projectsGetOutputSchema().Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, out)), &value)) + require.NoError(t, schema.Validate(value)) +} + +func TestTypedProjectsEmptyOutputs(t *testing.T) { + _, err := decodeProjectsListOutput(projectsMethodListProjectItems, nil) + require.Error(t, err, "empty legacy JSON must not produce a fabricated success") + for _, tc := range []struct { + name string + text string + }{ + {"empty", `{"items":[],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`}, + {"null_items", `{"items":null,"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`}, + {"null_payload", `null`}, + } { + t.Run(tc.name, func(t *testing.T) { + handler := projectsTypedHandler[ProjectsListInput]( + func(context.Context, ToolDependencies, *mcp.CallToolRequest, map[string]any) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: tc.text}}}, nil, nil + }, decodeProjectsListOutput) + result, out, err := handler(context.Background(), BaseDeps{}, nil, ProjectsListInput{ + Method: ProjectParameter[string]{Value: projectsMethodListProjectItems}, + }) + require.NoError(t, err) + assert.Equal(t, tc.text, getTextResult(t, result).Text) + schema, err := projectsListOutputSchema().Resolve(nil) + require.NoError(t, err) + var value map[string]any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, out)), &value)) + require.NoError(t, schema.Validate(value)) + assert.Equal(t, projectsMethodListProjectItems, value["method"]) + if tc.name == "null_payload" { + assert.NotContains(t, value, "items", "a null legacy payload must not become a fake populated result") + } + }) + } + get, err := decodeProjectsGetOutput(projectsMethodGetProject, []byte("null")) + require.NoError(t, err) + assert.JSONEq(t, `{"method":"get_project"}`, mustMarshalJSON(t, get)) + get, err = decodeProjectsGetOutput(projectsMethodGetProjectItem, []byte(`{"id":4}`)) + require.NoError(t, err) + assert.JSONEq(t, `{"method":"get_project_item","item":{"id":4}}`, mustMarshalJSON(t, get)) + getSchema, err := projectsGetOutputSchema().Resolve(nil) + require.NoError(t, err) + var getValue any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, get)), &getValue)) + require.NoError(t, getSchema.Validate(getValue), "optional fields array may be absent") + for _, method := range []string{projectsMethodCreateProject, projectsMethodUpdateProjectItem, projectsMethodAddProjectItem} { + out, err := decodeProjectsWriteOutput(method, []byte("null")) + require.NoError(t, err) + assert.JSONEq(t, `{"method":"`+method+`"}`, mustMarshalJSON(t, out)) + } +} + +func TestTypedProjectsRESTListPageShapes(t *testing.T) { + nextLink := http.Header{"Link": {`; rel="next"`}} + cases := []struct { + name string + tc projectWireCase + }{ + { + name: "projects_empty_final_page", + tc: projectWireCase{ + tool: "projects_list", method: projectsMethodListProjects, + steps: []projectWireStep{{method: "GET", path: "/orgs/o/projectsV2", query: "per_page=2", body: `[]`}}, + text: `{"pageInfo":{"hasNextPage":false,"hasPreviousPage":false},"projects":[]}`, + }, + }, + { + name: "fields_empty_final_page", + tc: projectWireCase{ + tool: "projects_list", method: projectsMethodListProjectFields, + steps: []projectWireStep{{method: "GET", path: "/orgs/o/projectsV2/2/fields", query: "per_page=2", body: `[]`}}, + text: `{"fields":[],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`, + }, + }, + { + name: "items_empty_final_page", + tc: projectWireCase{ + tool: "projects_list", method: projectsMethodListProjectItems, + steps: []projectWireStep{{method: "GET", path: "/orgs/o/projectsV2/2/items", query: "per_page=2", body: `[]`}}, + text: `{"items":[],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`, + }, + }, + { + name: "status_updates_empty_final_page", + tc: projectWireCase{ + tool: "projects_list", method: projectsMethodListProjectStatusUpdates, + steps: []projectWireStep{{method: "POST", path: "/graphql", body: `{"data":{"organization":{"projectV2":{"public":true,"statusUpdates":{"nodes":[],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"endCursor":"","startCursor":""}}}}}}`}}, + text: `{"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"nextCursor":"","prevCursor":""},"statusUpdates":[]}`, + }, + }, + { + name: "views_empty_final_page", + tc: projectWireCase{ + tool: "projects_list", method: projectsMethodListProjectViews, + steps: []projectWireStep{{method: "POST", path: "/graphql", body: `{"data":{"organization":{"projectV2":{"id":"P","public":true,"views":{"nodes":[],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"endCursor":"","startCursor":""}}}}}}`}}, + text: `{"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"nextCursor":"","prevCursor":""},"views":[]}`, + }, + }, + { + name: "projects_empty_next_page", + tc: projectWireCase{ + tool: "projects_list", method: projectsMethodListProjects, + steps: []projectWireStep{{method: "GET", path: "/orgs/o/projectsV2", query: "per_page=2", body: `[]`, header: nextLink}}, + text: `{"pageInfo":{"hasNextPage":true,"hasPreviousPage":false,"nextCursor":"next"},"projects":[]}`, + }, + }, + { + name: "fields_empty_next_page", + tc: projectWireCase{ + tool: "projects_list", method: projectsMethodListProjectFields, + steps: []projectWireStep{{ + method: "GET", path: "/orgs/o/projectsV2/2/fields", query: "per_page=2", body: `[]`, + header: http.Header{"Link": []string{`; rel="next"`}}, + }}, + text: `{"fields":[],"pageInfo":{"hasNextPage":true,"hasPreviousPage":false,"nextCursor":"next"}}`, + }, + }, + { + name: "items_empty_next_page", + tc: projectWireCase{ + tool: "projects_list", method: projectsMethodListProjectItems, + steps: []projectWireStep{{ + method: "GET", path: "/orgs/o/projectsV2/2/items", query: "per_page=2", body: `[]`, + header: http.Header{"Link": []string{`; rel="next"`}}, + }}, + text: `{"items":[],"pageInfo":{"hasNextPage":true,"hasPreviousPage":false,"nextCursor":"next"}}`, + }, + }, + } + + for _, protocol := range typedGitGistProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tc.tc.args = map[string]any{"perPage": 2} + session, schemas := projectsTypedSession(t, projectWireDeps(t, tc.tc), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tc.tc.tool, Arguments: projectWireArgs(tc.tc), + }) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + if schema := schemas[tc.tc.tool]; schema != nil { + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + assert.JSONEq(t, projectWireStructured(t, tc.tc), mustMarshalJSON(t, output)) + assert.Equal(t, mustMarshalJSON(t, output), getTextResult(t, result).Text) + } else { + assert.Equal(t, tc.tc.text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } + }) + } + }) + } +} + +func TestTypedProjectsSequentialPagination(t *testing.T) { + for _, protocol := range typedGitGistProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + tc := projectWireCase{ + tool: "projects_list", method: projectsMethodListProjectItems, + args: map[string]any{"perPage": 2}, + steps: []projectWireStep{ + {method: "GET", path: "/orgs/o/projectsV2/2/items", query: "per_page=2", body: `[]`, + header: http.Header{"Link": {`; rel="next"`}}}, + {method: "GET", path: "/orgs/o/projectsV2/2/items", query: "after=next&per_page=2", body: `[]`}, + }, + } + session, schemas := projectsTypedSession(t, projectWireDeps(t, tc), protocol) + args := projectWireArgs(tc) + for page, text := range []string{ + `{"items":[],"pageInfo":{"hasNextPage":true,"hasPreviousPage":false,"nextCursor":"next"}}`, + `{"items":[],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`, + } { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError) + var payload struct { + PageInfo pageInfo `json:"pageInfo"` + } + if schema := schemas[tc.tool]; schema != nil { + var output map[string]any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + tc.text = text + assert.JSONEq(t, projectWireStructured(t, tc), mustMarshalJSON(t, output)) + assert.Equal(t, mustMarshalJSON(t, output), getTextResult(t, result).Text) + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, output["items"])), &payload)) + } else { + assert.Equal(t, text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &payload)) + } + assert.Equal(t, page == 0, payload.PageInfo.HasNextPage) + if page == 0 { + require.Equal(t, "next", payload.PageInfo.NextCursor) + args["after"] = payload.PageInfo.NextCursor + } else { + assert.Empty(t, payload.PageInfo.NextCursor) + } + } + }) + } +} + +func TestTypedProjectsWriteBackendErrors(t *testing.T) { + cases := []struct { + tc projectWireCase + message string + }{{ + tc: projectWireCase{ + tool: "projects_write", method: projectsMethodUpdateProjectItem, + args: map[string]any{"item_id": "4", "updated_field": map[string]any{"id": 3, "value": "Updated"}}, + steps: []projectWireStep{{ + method: "PATCH", path: "/orgs/o/projectsV2/2/items/4", + body: `{"message":"server error"}`, status: http.StatusInternalServerError, + }}, + }, message: ProjectUpdateFailedError, + }, { + tc: projectWireCase{ + tool: "projects_write", method: projectsMethodDeleteProjectItem, + args: map[string]any{"item_id": 4}, + steps: []projectWireStep{{method: "DELETE", path: "/orgs/o/projectsV2/2/items/4", + status: http.StatusInternalServerError, body: `{"message":"server error"}`}}, + }, message: ProjectDeleteFailedError, + }, { + tc: projectWireCase{ + tool: "projects_write", method: projectsMethodCreateProjectStatusUpdate, + args: map[string]any{"body": "Update", "status": "ON_TRACK"}, + steps: []projectWireStep{ + {method: "POST", path: "/graphql", body: projectWireResolve}, + {method: "POST", path: "/graphql", body: `{"errors":[{"message":"mutation denied"}]}`}, + }, + }, message: ProjectStatusUpdateCreateFailedError, + }} + for _, protocol := range typedGitGistProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + for _, fixture := range cases { + t.Run(fixture.tc.method, func(t *testing.T) { + tc := fixture.tc + session, _ := projectsTypedSession(t, projectWireDeps(t, tc), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tc.tool, Arguments: projectWireArgs(tc), + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, fixture.message) + assert.Nil(t, result.StructuredContent) + }) + } + }) + } +} + +func TestTypedProjectsWireIFC(t *testing.T) { + for _, protocol := range typedGitGistProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + tc := projectWireCases()[5] + tc.steps[0].body = `{"id":1,"node_id":"P","title":"Roadmap","public":false,"number":2}` + tc.text = `{"id":1,"node_id":"P","title":"Roadmap","description":"","public":false,"closed_at":"0001-01-01T00:00:00Z","created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z","deleted_at":"0001-01-01T00:00:00Z","number":2,"short_description":""}` + deps := projectWireDeps(t, tc) + deps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + session, schemas := projectsTypedSession(t, deps, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool, Arguments: projectWireArgs(tc)}) + require.NoError(t, err) + require.False(t, result.IsError) + label := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "trusted", label["integrity"]) + assert.Equal(t, "private", label["confidentiality"]) + if schemas[tc.tool] != nil { + assert.JSONEq(t, projectWireStructured(t, tc), mustMarshalJSON(t, result.StructuredContent)) + assert.Equal(t, mustMarshalJSON(t, result.StructuredContent), getTextResult(t, result).Text) + } else { + assert.Equal(t, tc.text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedProjectsWireErrors(t *testing.T) { + cases := []struct { + tool string + args map[string]any + text string + }{ + {"projects_list", map[string]any{}, "missing required parameter: method"}, + {"projects_list", map[string]any{"method": true}, "parameter method is not of type string"}, + {"projects_list", map[string]any{"method": "unknown", "owner": "o"}, "unknown method: unknown"}, + {"projects_list", map[string]any{"method": "list_projects", "owner": "o", "owner_type": nil}, "parameter owner_type is not of type string, is "}, + {"projects_get", map[string]any{"method": "get_project", "owner": "o", "owner_type": "org", "project_number": "1.5"}, "parameter project_number is not a valid number: non-integer numeric value: 1.5"}, + {"projects_get", map[string]any{"method": "get_project_view"}, "missing required parameter: view_id"}, + {"projects_get", map[string]any{"method": "get_project_item", "owner": "o", "owner_type": "org", "project_number": 2, "item_id": 4, "fields": []any{true}}, "parameter fields is not of type string, is bool"}, + {"projects_write", map[string]any{"method": "update_project_item", "owner": "o", "owner_type": "org", "project_number": 2, "item_id": 4, "updated_field": false}, "updated_field must be an object"}, + {"projects_write", map[string]any{"method": "create_project", "owner": "o"}, "owner_type is required for create_project"}, + {"projects_write", map[string]any{"method": "create_project_status_update", "owner": "o", "owner_type": "org", "project_number": 2, "status": "BAD"}, `invalid status "BAD": must be one of INACTIVE, ON_TRACK, AT_RISK, OFF_TRACK, COMPLETE`}, + {"projects_write", map[string]any{"method": "update_project_items", "owner": "o", "owner_type": "org", "project_number": 2, "items": []any{}}, "items must contain at least one entry"}, + {"projects_write", map[string]any{"method": "delete_project_item", "owner": "o", "owner_type": "org", "project_number": 2, "ITEM_ID": 4}, "missing required parameter: item_id"}, + } + for _, protocol := range typedGitGistProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + session, _ := projectsTypedSession(t, projectWireDeps(t, projectWireCase{}), protocol) + for _, tc := range cases { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool, Arguments: tc.args}) + require.NoError(t, err) + require.True(t, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + assert.Equal(t, tc.text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedProjectsInputRoundtrip(t *testing.T) { + for _, tc := range []struct { + name string + input any + want string + }{ + {"list", ProjectsListInput{Method: ProjectParameter[string]{Value: "list_projects"}, Owner: ProjectParameter[string]{Value: "o"}}, `{"method":"list_projects","owner":"o"}`}, + {"get", ProjectsGetInput{Method: ProjectParameter[string]{Value: "get_project_view"}, ViewID: ProjectParameter[string]{Value: "V"}}, `{"method":"get_project_view","view_id":"V"}`}, + {"issue_reference", ProjectsWriteInput{ + Method: ProjectParameter[string]{Value: "update_project_item"}, + ItemOwner: ProjectParameter[string]{Value: "o"}, ItemRepo: ProjectParameter[string]{Value: "r"}, + IssueNumber: ProjectParameter[int]{Value: 7}, + }, `{"method":"update_project_item","item_owner":"o","item_repo":"r","issue_number":7}`}, + {"view_name_only", ProjectsWriteInput{Method: ProjectParameter[string]{Value: "update_project_view"}, Name: ProjectParameter[string]{Value: "New name"}}, `{"method":"update_project_view","name":"New name"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.JSONEq(t, tc.want, mustMarshalJSON(t, tc.input)) + }) + } + for _, input := range []any{&ProjectsListInput{}, &ProjectsGetInput{}, &ProjectsWriteInput{}} { + raw := `{"method":"example","owner":"o","project_number":0}` + require.NoError(t, json.Unmarshal([]byte(raw), input)) + assert.JSONEq(t, raw, mustMarshalJSON(t, input)) + } + raw := `{"method":"update_project_view","filter":null,"layout":"","item_id":0}` + var input ProjectsWriteInput + require.NoError(t, json.Unmarshal([]byte(raw), &input)) + assert.JSONEq(t, raw, mustMarshalJSON(t, input)) + require.NoError(t, json.Unmarshal([]byte(`{"method":"delete_project_item","ITEM_ID":4,"ItemID":5}`), &input)) + args, err := projectArguments(input) + require.NoError(t, err) + assert.NotContains(t, args, "item_id") + assert.JSONEq(t, `{"method":"delete_project_item"}`, mustMarshalJSON(t, input)) + require.NoError(t, json.Unmarshal([]byte(`{"method":"delete_project_item","item_id":3,"ITEM_ID":4}`), &input)) + assert.JSONEq(t, `{"method":"delete_project_item","item_id":3}`, mustMarshalJSON(t, input)) +} + +func TestTypedProjectsCaseSensitiveMutationTarget(t *testing.T) { + deps := projectWireDeps(t, projectWireCase{}) + request := createMCPRequest(map[string]any{ + "method": "delete_project_item", "owner": "o", "owner_type": "org", + "project_number": 2, "ITEM_ID": 4, + }) + tool := ProjectsWrite(translations.NullTranslationHelper) + result, err := tool.Handler(deps)( + ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, "missing required parameter: item_id", getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) +} diff --git a/pkg/github/typed_read_normalizers.go b/pkg/github/typed_read_normalizers.go new file mode 100644 index 0000000000..1bec77b7ce --- /dev/null +++ b/pkg/github/typed_read_normalizers.go @@ -0,0 +1,79 @@ +package github + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "github.com/github/github-mcp-server/v2/pkg/inventory" +) + +func normalizeTypedReadArguments(uppercaseFields []string, preserveZeroPage bool) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]json.RawMessage + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + if args == nil { + return raw, nil + } + + for _, field := range uppercaseFields { + var value string + if err := json.Unmarshal(args[field], &value); err == nil { + normalized, err := json.Marshal(strings.ToUpper(value)) + if err != nil { + return nil, err + } + args[field] = normalized + } + } + + for _, field := range []string{"page", "perPage"} { + value, exists := args[field] + if !exists { + continue + } + var number any + if err := json.Unmarshal(value, &number); err != nil { + return nil, err + } + if text, ok := number.(string); ok { + parsed, err := strconv.ParseFloat(text, 64) + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed != math.Trunc(parsed) || parsed > float64(math.MaxInt) || parsed < float64(math.MinInt) { + return nil, fmt.Errorf("parameter %s is not a valid number", field) + } + number = int(parsed) + } + if field == "page" && !preserveZeroPage { + if parsed, ok := number.(float64); ok && parsed == 0 { + delete(args, field) + continue + } + if parsed, ok := number.(int); ok && parsed == 0 { + delete(args, field) + continue + } + } + if field == "perPage" { + if parsed, ok := number.(float64); ok && parsed == 0 { + delete(args, field) + continue + } + if parsed, ok := number.(int); ok && parsed == 0 { + delete(args, field) + continue + } + } + normalized, err := json.Marshal(number) + if err != nil { + return nil, err + } + args[field] = normalized + } + + return json.Marshal(args) + } +} diff --git a/pkg/github/typed_repository_commit_outputs_test.go b/pkg/github/typed_repository_commit_outputs_test.go new file mode 100644 index 0000000000..59b5f207ff --- /dev/null +++ b/pkg/github/typed_repository_commit_outputs_test.go @@ -0,0 +1,218 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTypedRepositoryCommitPaginationDefaults(t *testing.T) { + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocol, func(t *testing.T) { + for _, tool := range []inventory.ServerTool{ + GetCommit(translations.NullTranslationHelper), + ListCommits(translations.NullTranslationHelper), + } { + t.Run(tool.Tool.Name, func(t *testing.T) { + for _, tc := range []struct { + name string + args map[string]any + }{ + {"omitted", map[string]any{}}, + {"numeric_zero", map[string]any{"page": 0, "perPage": 0}}, + {"string_zero", map[string]any{"page": "0", "perPage": "0"}}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + handler := func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, "1", r.URL.Query().Get("page")) + assert.Equal(t, "30", r.URL.Query().Get("per_page")) + var response any = &github.RepositoryCommit{SHA: new("abc123")} + if tool.Tool.Name == "list_commits" { + response = []*github.RepositoryCommit{{SHA: new("abc123")}} + } + mockResponse(t, http.StatusOK, response)(w, r) + } + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepoByRef: handler, + GetReposCommitsByOwnerByRepo: handler, + }))} + server := mcp.NewServer(&mcp.Implementation{Name: "commit-pagination-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + tool.RegisterFunc(server, deps) + session := connectCommentVisibilityClient(t, server, protocol) + tc.args["owner"] = "owner" + tc.args["repo"] = "repo" + if tool.Tool.Name == "get_commit" { + tc.args["sha"] = "abc123" + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: tc.args, + }) + require.NoError(t, err) + require.False(t, result.IsError, "%+v", result) + assert.Equal(t, 1, calls) + request := createMCPRequest(tc.args) + directResult, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, directResult.IsError, "%+v", directResult) + assert.Equal(t, 2, calls) + }) + } + }) + } + }) + } +} +func TestTypedRepositoryCommitOutputs(t *testing.T) { + commit := &github.RepositoryCommit{ + SHA: new("abc123"), + HTMLURL: new("https://github.com/owner/repo/commit/abc123"), + Commit: &github.Commit{Message: new("A commit")}, + } + handlers := map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{Private: new(false)}), + GetReposCommitsByOwnerByRepoByRef: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "2", r.URL.Query().Get("page")) + assert.Equal(t, "7", r.URL.Query().Get("per_page")) + mockResponse(t, http.StatusOK, commit)(w, r) + }, + GetReposCommitsByOwnerByRepo: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "4", r.URL.Query().Get("page")) + assert.Equal(t, "9", r.URL.Query().Get("per_page")) + if r.URL.Query().Get("sha") == "null" { + mockResponse(t, http.StatusOK, nil)(w, r) + return + } + if r.URL.Query().Get("sha") == "empty" { + mockResponse(t, http.StatusOK, []*github.RepositoryCommit{})(w, r) + return + } + mockResponse(t, http.StatusOK, []*github.RepositoryCommit{commit})(w, r) + }, + } + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(handlers)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + tools := []inventory.ServerTool{ + GetCommit(translations.NullTranslationHelper), + ListCommits(translations.NullTranslationHelper), + } + expectedGet := `{"sha":"abc123","html_url":"https://github.com/owner/repo/commit/abc123","commit":{"message":"A commit"}}` + expectedList := `[{"sha":"abc123","html_url":"https://github.com/owner/repo/commit/abc123","commit":{"message":"A commit"}}]` + + for _, protocolVersion := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocolVersion, func(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "typed-repository-commit-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + for _, tool := range tools { + tool.RegisterFunc(server, deps) + } + + session := connectCommentVisibilityClient(t, server, protocolVersion) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + outputSchemas := make(map[string]*mcp.Tool, len(list.Tools)) + for _, tool := range list.Tools { + outputSchemas[tool.Name] = tool + if protocolVersion == "2025-11-25" { + assert.Nil(t, tool.OutputSchema, "%s must not expose outputSchema to legacy clients", tool.Name) + } else { + require.NotNil(t, tool.OutputSchema, "%s must expose its typed output schema", tool.Name) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + } + } + + calls := []struct { + name string + args map[string]any + text string + }{ + { + name: "get_commit", + args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123", "detail": "none", "page": "2", "perPage": "7"}, + text: expectedGet, + }, + { + name: "list_commits", + args: map[string]any{"owner": "owner", "repo": "repo", "page": "4", "perPage": "9"}, + text: expectedList, + }, + { + name: "list_commits", + args: map[string]any{"owner": "owner", "repo": "repo", "page": "4", "perPage": "9", "fields": []any{"sha"}}, + text: `[{"sha":"abc123"}]`, + }, + { + name: "list_commits", + args: map[string]any{"owner": "owner", "repo": "repo", "page": "4", "perPage": "9", "fields": []any{"commit"}}, + text: `[{"commit":{"message":"A commit"}}]`, + }, + { + name: "list_commits", + args: map[string]any{"owner": "owner", "repo": "repo", "page": "4", "perPage": "9", "sha": "empty"}, + text: `[]`, + }, + { + name: "list_commits", + args: map[string]any{"owner": "owner", "repo": "repo", "page": "4", "perPage": "9", "sha": "empty", "fields": []any{"sha", "commit"}}, + text: `[]`, + }, + { + name: "list_commits", + args: map[string]any{"owner": "owner", "repo": "repo", "page": "4", "perPage": "9", "sha": "null"}, + text: `[]`, + }, + } + for _, call := range calls { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: call.name, Arguments: call.args}) + require.NoError(t, err, call.name) + require.False(t, result.IsError, "%s: %s", call.name, result) + require.Len(t, result.Content, 1, "%s must preserve one legacy text result", call.name) + assert.NotNil(t, result.Meta["ifc"], "typed and legacy outputs retain IFC labeling") + text := getTextResult(t, result).Text + if protocolVersion == "2025-11-25" { + assert.Equal(t, call.text, text, "%s legacy text is byte-exact", call.name) + assert.Nil(t, result.StructuredContent) + continue + } + + require.NotNil(t, result.StructuredContent, "%s must return structured content", call.name) + structuredJSON := mustMarshalJSON(t, result.StructuredContent) + assert.JSONEq(t, call.text, text) + assert.JSONEq(t, text, structuredJSON, "modern text must serialize the typed DTO") + + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, outputSchemas[call.name].OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + var output any + require.NoError(t, json.Unmarshal([]byte(structuredJSON), &output)) + require.NoError(t, resolved.Validate(output), "%s output must conform to its schema", call.name) + } + for _, call := range []mcp.CallToolParams{ + {Name: "get_commit", Arguments: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123", "detail": "invalid"}}, + {Name: "list_commits", Arguments: map[string]any{"owner": "owner", "repo": "repo", "since": "invalid"}}, + } { + result, err := session.CallTool(context.Background(), &call) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent, "errors must not expose a success DTO") + } + + }) + } +} diff --git a/pkg/github/typed_repository_outputs_test.go b/pkg/github/typed_repository_outputs_test.go new file mode 100644 index 0000000000..672837c711 --- /dev/null +++ b/pkg/github/typed_repository_outputs_test.go @@ -0,0 +1,596 @@ +package github + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "maps" + "net/http" + "strings" + "testing" + "time" + + "github.com/github/github-mcp-server/v2/internal/requeststate" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func repositoryOutputFixtures() (*github.Reference, *github.Commit, *github.RepositoryRelease) { + ref := &github.Reference{Ref: new("refs/heads/main"), URL: new("ref-url"), + Object: &github.GitObject{Type: new("commit"), SHA: new("base"), URL: new("commit-url")}} + commit := &github.Commit{SHA: new("new"), Message: new("change"), + HTMLURL: new("https://github.com/owner/repo/commit/new"), URL: new("api-commit-url"), + Author: &github.CommitAuthor{Date: &github.Timestamp{Time: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)}}, + Tree: &github.Tree{SHA: new("tree")}, Parents: []*github.Commit{{SHA: new("base")}}} + release := &github.RepositoryRelease{ID: 7, TagName: "v1", Name: new("Release"), + HTMLURL: "https://github.com/owner/repo/releases/tag/v1", + URL: "api-release-url", AssetsURL: "api-assets-url", UploadURL: "api-upload-url", + Body: new("No\u200btes"), CreatedAt: github.Timestamp{Time: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)}, + Author: &github.User{Login: new("octo")}, Assets: []*github.ReleaseAsset{{ + Name: new("app.zip"), Size: new(3), Digest: new("sha256:abc"), + }}} + return ref, commit, release +} + +func typedRepositoryDeps(t *testing.T) BaseDeps { + t.Helper() + ref, commit, release := repositoryOutputFixtures() + handlers := map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}": mockResponse(t, http.StatusOK, &github.Repository{ID: new(int64(1)), DefaultBranch: new("main")}), + "POST /user/repos": func(w http.ResponseWriter, r *http.Request) { + var body github.Repository + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "created", body.GetName()) + if body.GetDescription() == "public" { + assert.False(t, body.GetPrivate()) + } else { + assert.True(t, body.GetPrivate(), "omitted private retains the secure default") + } + mockResponse(t, http.StatusCreated, &github.Repository{ID: new(int64(8)), HTMLURL: new("repo-url")})(w, r) + }, + "GET /repos/{owner}/{repo}/contents/{path...}": func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/forbidden"): + mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"})(w, r) + case strings.HasSuffix(r.URL.Path, "/new"): + mockResponse(t, http.StatusNotFound, map[string]string{"message": "Not Found"})(w, r) + case strings.HasSuffix(r.URL.Path, "/directory"): + mockResponse(t, http.StatusOK, []*github.RepositoryContent{{ + Name: new("file"), Type: new("file"), SHA: new("sha"), Size: new(0), + URL: new("api-content-url"), GitURL: new("api-git-url"), + HTMLURL: new("https://github.com/owner/repo/blob/main/file"), + DownloadURL: new("https://raw.githubusercontent.com/owner/repo/main/file"), + }})(w, r) + case strings.HasSuffix(r.URL.Path, "/submodule-directory"): + mockResponse(t, http.StatusOK, []*github.RepositoryContent{{ + Name: new("lib"), Path: new("lib"), Type: new("file"), SHA: new("sha"), + SubmoduleGitURL: new("https://github.com/owner/lib.git"), + }})(w, r) + case strings.HasSuffix(r.URL.Path, "/empty-directory"): + mockResponse(t, http.StatusOK, []*github.RepositoryContent{})(w, r) + case strings.HasSuffix(r.URL.Path, "/submodule"): + mockResponse(t, http.StatusOK, &github.RepositoryContent{Type: new("submodule"), SHA: new("abc"), SubmoduleGitURL: new("git-url")})(w, r) + case strings.HasSuffix(r.URL.Path, "/symlink"): + mockResponse(t, http.StatusOK, &github.RepositoryContent{Type: new("symlink"), SHA: new("abc"), Target: new("../outside")})(w, r) + case strings.HasSuffix(r.URL.Path, "/missing"): + mockResponse(t, http.StatusNotFound, map[string]string{"message": "Not Found"})(w, r) + default: + data := []byte("hello\n") + if strings.HasSuffix(r.URL.Path, "/binary") { + data = []byte{0, 1, 2} + } + if strings.HasSuffix(r.URL.Path, "/empty") { + data = []byte{} + } + size := len(data) + if strings.HasSuffix(r.URL.Path, "/large") { + size = 1024 * 1024 + } + mockResponse(t, http.StatusOK, &github.RepositoryContent{ + Type: new("file"), Name: new("file"), SHA: new(gitBlobSHA(data)), + Size: new(size), Encoding: new("base64"), + Content: new(base64.StdEncoding.EncodeToString(data)), DownloadURL: new("download-url"), + })(w, r) + } + }, + "PUT /repos/{owner}/{repo}/contents/{path...}": func(w http.ResponseWriter, r *http.Request) { + var body github.RepositoryContentFileOptions + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, []byte("plain text"), body.Content, "mutation input is not double base64 encoded") + assert.Equal(t, "main", body.GetBranch()) + mockResponse(t, http.StatusCreated, &github.RepositoryContentResponse{ + Content: &github.RepositoryContent{Path: new("new"), SHA: new("blob")}, + Commit: *commit, + })(w, r) + }, + GetReposGitRefByOwnerByRepoByRef: func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/forbidden") { + mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"})(w, r) + return + } + if strings.HasSuffix(r.URL.Path, "/annotated") { + mockResponse(t, http.StatusOK, &github.Reference{Ref: new("refs/tags/annotated"), Object: &github.GitObject{ + Type: new("tag"), SHA: new("tag-sha"), + }})(w, r) + return + } + mockResponse(t, http.StatusOK, ref)(w, r) + }, + "GET /repos/{owner}/{repo}/git/tags/{tag_sha}": mockResponse(t, http.StatusOK, &github.Tag{ + Tag: new("annotated"), Message: new("Tag message"), SHA: new("tag-sha"), + Tagger: &github.CommitAuthor{Date: commit.Author.Date}, URL: new("api-tag-url"), + }), + "GET /repos/{owner}/{repo}/git/commits/{commit_sha}": mockResponse(t, http.StatusOK, commit), + "POST /repos/{owner}/{repo}/git/trees": func(w http.ResponseWriter, r *http.Request) { + var body struct { + Entries []github.TreeEntry `json:"tree"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Len(t, body.Entries, 1) + assert.Equal(t, "file", body.Entries[0].GetPath()) + if body.Entries[0].Content != nil { + assert.Equal(t, "", body.Entries[0].GetContent(), "empty push content is valid") + } else { + assert.Nil(t, body.Entries[0].SHA, "delete retains null SHA semantics") + } + mockResponse(t, http.StatusCreated, &github.Tree{SHA: new("tree")})(w, r) + }, + "POST /repos/{owner}/{repo}/git/commits": mockResponse(t, http.StatusCreated, commit), + "POST /repos/{owner}/{repo}/git/refs": func(w http.ResponseWriter, r *http.Request) { + var body github.CreateRef + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "refs/heads/feature", body.Ref) + assert.Equal(t, "base", body.SHA) + mockResponse(t, http.StatusCreated, ref)(w, r) + }, + PatchReposGitRefsByOwnerByRepoByRef: func(w http.ResponseWriter, r *http.Request) { + var body github.UpdateRef + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.NotNil(t, body.Force) + assert.False(t, *body.Force) + assert.Equal(t, "new", body.SHA) + mockResponse(t, http.StatusOK, ref)(w, r) + }, + "GET /repos/{owner}/{repo}/git/trees/{tree_sha}": mockResponse(t, http.StatusOK, &github.Tree{Entries: []*github.TreeEntry{{ + Path: new("nested/missing"), Type: new("blob"), + }}}), + "POST /repos/{owner}/{repo}/forks": func(w http.ResponseWriter, r *http.Request) { + var body github.RepositoryCreateForkOptions + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + if body.Organization == "pending" { + mockResponse(t, http.StatusAccepted, map[string]string{"message": "pending"})(w, r) + return + } + mockResponse(t, http.StatusAccepted, &github.Repository{ID: new(int64(9)), HTMLURL: new("fork-url")})(w, r) + }, + "GET /repos/{owner}/{repo}/releases/latest": mockResponse(t, http.StatusOK, release), + "GET /repos/{owner}/{repo}/releases/tags/{tag}": mockResponse(t, http.StatusOK, release), + "GET /repos/{owner}/{repo}/releases": func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") == "2" { + assert.Equal(t, "7", r.URL.Query().Get("per_page")) + } else { + assert.Equal(t, "1", r.URL.Query().Get("page")) + assert.Equal(t, "30", r.URL.Query().Get("per_page")) + } + if strings.Contains(r.URL.Path, "/empty/") { + mockResponse(t, http.StatusOK, []*github.RepositoryRelease{})(w, r) + return + } + mockResponse(t, http.StatusOK, []*github.RepositoryRelease{release})(w, r) + }, + "GET /user/starred": mockResponse(t, http.StatusOK, []*github.StarredRepository{{Repository: &github.Repository{ + ID: new(int64(1)), Name: new("repo"), FullName: new("owner/repo"), + }}}), + "GET /users/{user}/starred": mockResponse(t, http.StatusOK, []*github.StarredRepository{}), + "PUT /user/starred/{owner}/{repo}": mockResponse(t, http.StatusNoContent, nil), + "DELETE /user/starred/{owner}/{repo}": mockResponse(t, http.StatusNoContent, nil), + "GET /repos/{owner}/{repo}/collaborators": func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "2", r.URL.Query().Get("page")) + assert.Equal(t, "7", r.URL.Query().Get("per_page")) + assert.Equal(t, "outside", r.URL.Query().Get("affiliation")) + if strings.Contains(r.URL.Path, "/empty/") { + mockResponse(t, http.StatusOK, []*github.User{})(w, r) + return + } + w.Header().Set("Link", `; rel="next"`) + mockResponse(t, http.StatusOK, []*github.User{{Login: new("octo"), ID: new(int64(1)), RoleName: new("write")}})(w, r) + }, + } + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"repository":{"defaultBranchRef":{"name":"main"},"object":{"__typename":"Commit","blame":{"ranges":[{"startingLine":1,"endingLine":3,"age":0,"commit":{"oid":"abc","message":"Message","committedDate":"2026-01-02T00:00:00Z","author":{"name":"Author","email":"author@example.com","user":null}}}]}}}}}`)) + })}} + return BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(handlers)), GQLClient: githubv4.NewClient(gql), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } +} + +func TestTypedRepositoryOutputs(t *testing.T) { + translate := translations.NullTranslationHelper + tools := []inventory.ServerTool{ + CreateOrUpdateFile(translate), CreateRepository(translate), DeleteRepository(translate), + GetFileContents(translate), ForkRepository(translate), DeleteFile(translate), CreateBranch(translate), + PushFiles(translate), GetTag(translate), ListReleases(translate), GetLatestRelease(translate), + GetReleaseByTag(translate), ListStarredRepositories(translate), StarRepository(translate), + UnstarRepository(translate), GetFileBlame(translate), ListRepositoryCollaborators(translate), + } + inputContracts := make(map[string]string, len(tools)) + for _, tool := range tools { + inputContracts[tool.Tool.Name] = mustMarshalJSON(t, tool.Tool.InputSchema) + } + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return true, nil }).Build() + require.NoError(t, err) + coord := func(extra map[string]any) map[string]any { + args := map[string]any{"owner": "owner", "repo": "repo"} + maps.Copy(args, extra) + return args + } + calls := []struct { + name string + args map[string]any + }{ + {"create_repository", map[string]any{"name": "created"}}, + {"create_repository", map[string]any{"name": "created", "private": false, "description": "public"}}, + {"create_or_update_file", coord(map[string]any{"path": "new", "content": "plain text", "message": "change", "branch": "main"})}, + {"create_branch", coord(map[string]any{"branch": "feature"})}, + {"push_files", coord(map[string]any{"branch": "main", "message": "change", "files": []any{map[string]any{"path": "file", "content": ""}}})}, + {"delete_file", coord(map[string]any{"path": "file", "message": "delete", "branch": "main"})}, + {"fork_repository", coord(nil)}, + {"fork_repository", coord(map[string]any{"organization": "pending"})}, + {"get_tag", coord(map[string]any{"tag": "lightweight"})}, + {"get_tag", coord(map[string]any{"tag": "annotated"})}, + {"list_releases", coord(map[string]any{"page": "2.0", "perPage": "7"})}, + {"list_releases", coord(nil)}, + {"list_releases", coord(map[string]any{"page": 0, "perPage": 0})}, + {"list_releases", coord(map[string]any{"page": "0", "perPage": "0"})}, + {"list_releases", coord(map[string]any{"page": "2", "perPage": "7", "fields": []any{"id", "draft"}})}, + {"list_releases", coord(map[string]any{"repo": "empty", "page": "2", "perPage": "7"})}, + {"get_latest_release", coord(nil)}, + {"get_release_by_tag", coord(map[string]any{"tag": "v1"})}, + {"list_starred_repositories", map[string]any{}}, + {"list_starred_repositories", map[string]any{"username": "empty"}}, + {"star_repository", coord(nil)}, + {"unstar_repository", coord(nil)}, + {"list_repository_collaborators", coord(map[string]any{"page": "2", "perPage": "7", "affiliation": "outside"})}, + {"list_repository_collaborators", coord(map[string]any{"repo": "empty", "page": "2", "perPage": "7", "affiliation": "outside"})}, + {"get_file_blame", coord(map[string]any{"path": "file", "start_line": "2", "end_line": "3", "perPage": "1"})}, + {"get_file_blame", coord(map[string]any{"path": "file", "after": encodeBlameCursor(1)})}, + } + for _, path := range []string{"text", "empty", "binary", "large", "submodule", "symlink", "directory", "submodule-directory", "empty-directory", "missing"} { + calls = append(calls, struct { + name string + args map[string]any + }{"get_file_contents", coord(map[string]any{"path": path, "sha": strings.Repeat("a", 40)})}) + } + calls = append(calls, struct { + name string + args map[string]any + }{"get_file_contents", coord(map[string]any{"path": "directory", "sha": strings.Repeat("a", 40), "fields": []any{"name"}})}) + calls = append(calls, struct { + name string + args map[string]any + }{"get_file_contents", coord(map[string]any{"path": "directory", "sha": strings.Repeat("a", 40), "fields": []any{"size", "html_url", "url"}})}) + + for _, test := range []struct { + protocol string + lockdown bool + }{ + {"2025-11-25", false}, {inventory.ProtocolVersionMultiRoundTrip, false}, {"", false}, + {"2025-11-25", true}, {inventory.ProtocolVersionMultiRoundTrip, true}, {"", true}, + } { + protocol := test.protocol + t.Run(fmt.Sprintf("protocol=%s/lockdown=%t", protocol, test.lockdown), func(t *testing.T) { + deps := typedRepositoryDeps(t) + deps.Flags.LockdownMode = test.lockdown + server := mcp.NewServer(&mcp.Implementation{Name: "typed-repository-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + if protocol == "" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch request := req.(type) { + case *mcp.ListToolsRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + case *mcp.CallToolRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + } + return next(ctx, method, req) + } + }) + } + version := protocol + if version == "" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 16, "deletion remains hidden without form elicitation") + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + var inputSchema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.InputSchema)), &inputSchema)) + for _, property := range inputSchema.Properties { + delete(property.Extra, "x-mcp-header") + } + assert.JSONEq(t, inputContracts[tool.Name], mustMarshalJSON(t, inputSchema), "input metadata remains unchanged") + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, tool.Name) + require.NoError(t, toolsnaps.Test(tool.Name, *tool)) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + schemas[tool.Name], err = schema.Resolve(nil) + require.NoError(t, err, tool.Name) + } + for _, call := range calls { + t.Run(call.name+"/"+mustMarshalJSON(t, call.args), func(t *testing.T) { + // Direct invocation provides the legacy content baseline without SDK + // serialization. Compare every content block, not just a JSON proxy. + var tool inventory.ServerTool + for _, candidate := range tools { + if candidate.Tool.Name == call.name { + tool = candidate + break + } + } + request := createMCPRequest(call.args) + legacy, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, legacy.IsError, legacy) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: call.name, Arguments: call.args}) + require.NoError(t, err) + require.False(t, result.IsError, result) + assert.JSONEq(t, mustMarshalJSON(t, legacy.Meta["ifc"]), mustMarshalJSON(t, result.Meta["ifc"]), "IFC labels remain protocol-independent") + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Equal(t, mustMarshalJSON(t, legacy.Content), mustMarshalJSON(t, result.Content), "legacy content blocks remain byte-exact") + assertLegacyRepositoryText(t, call.name, call.args, result) + assert.Nil(t, result.StructuredContent) + return + } + require.NotNil(t, result.StructuredContent) + if hasOnlyTextContent(result.Content) { + assert.JSONEq(t, mustMarshalJSON(t, result.StructuredContent), getTextResult(t, result).Text, + "modern JSON text is the compact structured output") + } else { + assert.Equal(t, mustMarshalJSON(t, legacy.Content), mustMarshalJSON(t, result.Content), + "non-text resource blocks preserve legacy content") + } + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schemas[call.name].Validate(output)) + first := result.Content[0].(*mcp.TextContent).Text + switch { + case call.name == "get_file_contents" && !strings.HasPrefix(first, "["): + assert.JSONEq(t, `{"content":`+mustMarshalJSON(t, legacy.Content)+`}`, mustMarshalJSON(t, result.StructuredContent)) + case call.name == "get_file_contents": + var entries []*RepositoryDirectoryEntryOutput + require.NoError(t, json.Unmarshal([]byte(first), &entries)) + assert.JSONEq(t, mustMarshalJSON(t, entries), mustMarshalJSON(t, result.StructuredContent)) + if call.args["fields"] == nil { + assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "api-content-url") + } + assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "api-git-url") + if call.args["path"] == "submodule-directory" { + assert.JSONEq(t, `[{"name":"lib","path":"lib","type":"file","sha":"sha","submodule_git_url":"https://github.com/owner/lib.git"}]`, mustMarshalJSON(t, result.StructuredContent)) + } + if call.args["path"] == "directory" && call.args["fields"] != nil && len(call.args["fields"].([]any)) == 3 { + assert.JSONEq(t, `[{"size":0,"html_url":"https://github.com/owner/repo/blob/main/file","url":"api-content-url"}]`, mustMarshalJSON(t, result.StructuredContent)) + } + case call.name == "create_branch" || call.name == "push_files": + assert.JSONEq(t, `{"ref":"refs/heads/main","object":{"type":"commit","sha":"base"}}`, mustMarshalJSON(t, result.StructuredContent)) + case call.name == "get_tag" && call.args["tag"] == "lightweight": + assert.JSONEq(t, `{"ref":"refs/heads/main","object":{"type":"commit","sha":"base"}}`, mustMarshalJSON(t, result.StructuredContent)) + case call.name == "get_tag": + assert.JSONEq(t, `{"tag":"annotated","sha":"tag-sha","message":"Tag message","tagger":{"date":"2026-01-02T03:04:05Z"}}`, mustMarshalJSON(t, result.StructuredContent)) + case call.name == "delete_file": + assert.JSONEq(t, `{"commit":{"sha":"new","message":"change","html_url":"https://github.com/owner/repo/commit/new","author":{"date":"2026-01-02T03:04:05Z"},"tree_sha":"tree","parents":["base"]},"content":null}`, mustMarshalJSON(t, result.StructuredContent)) + case call.name == "get_latest_release" || call.name == "get_release_by_tag": + assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "\u200b") + assert.JSONEq(t, `{"id":7,"tag_name":"v1","name":"Release","body":"Notes","html_url":"https://github.com/owner/repo/releases/tag/v1","prerelease":false,"draft":false,"author":{"login":"octo"}}`, mustMarshalJSON(t, result.StructuredContent)) + case call.name == "star_repository" || call.name == "unstar_repository" || (call.name == "fork_repository" && call.args["organization"] == "pending"): + assert.JSONEq(t, mustMarshalJSON(t, RepositoryMessageOutput{Message: getTextResult(t, legacy).Text}), mustMarshalJSON(t, result.StructuredContent)) + default: + } + for _, tool := range tools { + assert.Equal(t, inputContracts[tool.Tool.Name], mustMarshalJSON(t, tool.Tool.InputSchema), "registration/calls must not mutate tool contracts") + } + }) + } + for _, call := range []struct { + name string + args map[string]any + }{ + {"create_repository", map[string]any{"name": ""}}, + {"get_tag", coord(map[string]any{"tag": ""})}, + {"get_tag", coord(map[string]any{"tag": "forbidden"})}, + {"get_file_contents", coord(map[string]any{"path": "forbidden", "sha": strings.Repeat("a", 40)})}, + {"get_file_blame", coord(map[string]any{"path": "../file"})}, + {"push_files", coord(map[string]any{"branch": "main", "message": "change", "files": "invalid"})}, + {"delete_file", coord(map[string]any{"branch": "main", "path": "../file", "message": "delete"})}, + } { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: call.name, Arguments: call.args}) + require.NoError(t, err) + require.True(t, result.IsError, result) + assert.Nil(t, result.StructuredContent, "errors never return success-shaped output") + assert.NotContains(t, result.Meta, "ifc", "errors never receive success IFC labels") + } + }) + } +} + +func hasOnlyTextContent(content []mcp.Content) bool { + for _, item := range content { + if _, ok := item.(*mcp.TextContent); !ok { + return false + } + } + return true +} + +func assertLegacyRepositoryText(t *testing.T, name string, args map[string]any, result *mcp.CallToolResult) { + t.Helper() + switch name { + case "create_branch", "push_files": + ref, _, _ := repositoryOutputFixtures() + assert.Equal(t, mustMarshalJSON(t, ref), getTextResult(t, result).Text) + case "get_tag": + ref, commit, _ := repositoryOutputFixtures() + if args["tag"] == "lightweight" { + assert.Equal(t, mustMarshalJSON(t, ref), getTextResult(t, result).Text) + return + } + tag := &github.Tag{ + Tag: new("annotated"), Message: new("Tag message"), SHA: new("tag-sha"), + Tagger: &github.CommitAuthor{Date: commit.Author.Date}, URL: new("api-tag-url"), + } + assert.Equal(t, mustMarshalJSON(t, tag), getTextResult(t, result).Text) + case "delete_file": + _, commit, _ := repositoryOutputFixtures() + assert.Equal(t, mustMarshalJSON(t, map[string]any{"commit": commit, "content": nil}), getTextResult(t, result).Text) + case "get_latest_release", "get_release_by_tag": + _, _, release := repositoryOutputFixtures() + sanitizeReleaseNameAndBody(release) + assert.Equal(t, mustMarshalJSON(t, release), getTextResult(t, result).Text) + case "list_repository_collaborators": + if args["repo"] != "empty" { + assert.Equal(t, `{"firstPage":0,"items":[{"login":"octo","id":1,"role_name":"write"}],"lastPage":0,"nextPage":3,"prevPage":0}`, getTextResult(t, result).Text) + } + case "get_file_contents": + fields, ok := args["fields"].([]any) + if args["path"] == "directory" && ok && len(fields) == 3 { + assert.Equal(t, `[{"html_url":"https://github.com/owner/repo/blob/main/file","size":0,"url":"api-content-url"}]`, getTextResult(t, result).Text) + } + } +} + +func TestTypedRepositoryDeletionOutput(t *testing.T) { + deps := typedRepositoryDeps(t) + sealer, err := requeststate.NewRandom() + require.NoError(t, err) + deps.StateSealer = sealer + deps.Client = mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{ID: new(int64(1))}), + DeleteReposByOwnerByRepo: mockResponse(t, http.StatusNoContent, nil), + })) + tool := DeleteRepository(translations.NullTranslationHelper) + server := mcp.NewServer(&mcp.Implementation{Name: "repository-deletion-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + tool.RegisterFunc(server, deps) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "deletion-client", Version: "v1"}, &mcp.ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{deleteRepositoryConfirmationField: "owner/repo"}}, nil + }, + }) + session, err := client.Connect(context.Background(), commentVisibilityProtocolTransport{clientTransport, inventory.ProtocolVersionMultiRoundTrip}, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + require.NoError(t, toolsnaps.Test("delete_repository", *list.Tools[0])) + request := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo"}) + pending, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.Len(t, pending.InputRequests, 1) + assert.Nil(t, pending.StructuredContent, "confirmation is not a completed deletion") + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "delete_repository", Arguments: map[string]any{"owner": "owner", "repo": "repo"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, `{"message":"Repository owner/repo was deleted."}`, getTextResult(t, result).Text) + assert.JSONEq(t, `{"message":"Repository owner/repo was deleted."}`, mustMarshalJSON(t, result.StructuredContent)) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, list.Tools[0].OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, resolved.Validate(output)) +} + +func TestRepositoryOutputUnions(t *testing.T) { + for _, test := range []struct { + name string + schema *jsonschema.Schema + valid []string + invalid []string + }{ + { + "tag", repositoryTagOutputSchema(), + []string{`null`, `{"ref":"refs/tags/v1","object":{"type":"commit","sha":"abc"}}`, `{"tag":"v1","sha":"tag-sha","tagger":{"date":"2026-01-02T00:00:00Z"}}`}, + []string{`{}`, `{"ref":"refs/tags/v1"}`, `{"ref":"refs/tags/v1","object":{"type":"commit","sha":"abc"},"tag":"v1"}`, `{"message":1}`}, + }, + { + "fork", forkRepositoryOutputSchema(), + []string{`null`, `{"id":"9","url":"fork-url"}`, `{"message":"Fork is in progress"}`}, + []string{`{}`, `{"id":"9"}`, `{"id":"9","url":"fork-url","message":"pending"}`}, + }, + { + "contents", repositoryContentsOutputSchema(), + []string{`null`, `[]`, `[{"name":"file"}]`, `{"content":[]}`, `{"content":[{"type":"text","text":"status"}]}`, + `{"content":[{"type":"resource","resource":{"uri":"repo://file","mimeType":"text/plain"}}]}`, + `{"content":[{"type":"resource","resource":{"uri":"repo://file","blob":"AAEC"}}]}`, + `{"content":[{"type":"resource_link","uri":"repo://file","name":"file","size":1048576}]}`}, + []string{`{}`, `{"content":[{"type":"text"}]}`, `{"content":[{"type":"resource","resource":{"uri":"repo://file","text":"x","blob":"AA=="}}]}`, + `{"content":[{"type":"opaque","data":{}}]}`}, + }, + } { + t.Run(test.name, func(t *testing.T) { + resolved, err := test.schema.Resolve(nil) + require.NoError(t, err) + for _, text := range test.valid { + var output any + require.NoError(t, json.Unmarshal([]byte(text), &output)) + require.NoError(t, resolved.Validate(output), text) + } + for _, text := range test.invalid { + var output any + require.NoError(t, json.Unmarshal([]byte(text), &output)) + require.Error(t, resolved.Validate(output), text) + } + }) + } +} + +func TestRepositoryPaginationCompatibility(t *testing.T) { + for _, raw := range []string{`{}`, `{"page":0,"perPage":0}`, `{"page":"0","perPage":"0"}`} { + normalized, err := normalizeRepositoryArguments(json.RawMessage(raw)) + require.NoError(t, err) + var input ListReleasesInput + require.NoError(t, json.Unmarshal(normalized, &input)) + assert.Equal(t, PaginationParams{Page: 1, PerPage: 30}, repositoryPagination(input.Page, input.PerPage)) + } + fields, err := normalizeRepositoryFieldsArguments(json.RawMessage(`{"page":"2.0","perPage":"7","fields":null}`)) + require.NoError(t, err) + normalized, err := normalizeRepositoryArguments(fields) + require.NoError(t, err) + assert.JSONEq(t, `{"page":2,"perPage":7}`, string(normalized)) + for _, raw := range []string{`{"page":"1.5"}`, `{"perPage":"NaN"}`} { + _, err := normalizeRepositoryArguments(json.RawMessage(raw)) + require.Error(t, err) + } + for _, raw := range []string{`{"start_line":"0"}`, `{"end_line":0}`, `{"perPage":"0"}`} { + normalized, err := normalizeRepositoryBlameArguments(json.RawMessage(raw)) + require.NoError(t, err) + var input GetFileBlameInput + require.NoError(t, json.Unmarshal(normalized, &input)) + assert.True(t, input.StartLine != nil || input.EndLine != nil || input.PerPage != nil, "blame preserves explicit zero for validation") + } +} diff --git a/pkg/github/typed_search_outputs_test.go b/pkg/github/typed_search_outputs_test.go new file mode 100644 index 0000000000..93aab4ff99 --- /dev/null +++ b/pkg/github/typed_search_outputs_test.go @@ -0,0 +1,385 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTypedProjectedReadToolOutputSchemas(t *testing.T) { + tools := []inventory.ServerTool{ + SearchCode(translations.NullTranslationHelper), + SearchIssues(translations.NullTranslationHelper, WithHost(utils.HostTypeGHES)), + SearchPullRequests(translations.NullTranslationHelper), + ListIssues(translations.NullTranslationHelper), + ListPullRequests(translations.NullTranslationHelper), + ListBranches(translations.NullTranslationHelper), + ListTags(translations.NullTranslationHelper), + } + for _, tool := range tools { + assert.Equal(t, []string{"repo"}, tool.ScopeAccess.Scopes, "%s must retain repository read scope", tool.Tool.Name) + } + deps := BaseDeps{ + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCode: mockResponse(t, http.StatusOK, &github.CodeSearchResult{ + Total: new(1), + IncompleteResults: new(false), + CodeResults: []*github.CodeResult{{ + Name: new("main.go"), + Path: new("cmd/main.go"), + SHA: new("abc123"), + Repository: &github.Repository{ + FullName: new("owner/repo"), + Private: new(false), + }, + TextMatches: []*github.TextMatch{{ + ObjectType: new("FileContent"), + Property: new("content"), + Fragment: new("func main()"), + Matches: []*github.Match{{ + Text: new("main"), + Indices: []int{5, 9}, + }}, + }}, + }}, + }), + GetSearchIssues: mockResponse(t, http.StatusOK, &github.IssuesSearchResult{ + Total: new(1), + IncompleteResults: new(false), + Issues: []*github.Issue{{ + ID: new(int64(42)), + URL: new("https://api.github.com/repos/owner/repo/issues/42"), + Number: new(42), + Title: new("A title"), + Body: new("A body"), + State: new("open"), + }}, + }), + GetReposBranchesByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.Branch{}), + GetReposTagsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.RepositoryTag{ + {Name: new("v1.0.0"), Commit: &github.Commit{SHA: new("abc123")}}, + }), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{Private: new(false)}), + })), + } + + for _, protocolVersion := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocolVersion, func(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "typed-projected-read-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + for _, tool := range tools { + tool.RegisterFunc(server, deps) + } + + session := connectCommentVisibilityClient(t, server, protocolVersion) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + outputSchemas := make(map[string]*jsonschema.Resolved, len(list.Tools)) + for _, tool := range list.Tools { + if protocolVersion == "2025-11-25" { + assert.Nil(t, tool.OutputSchema, "legacy clients must not see outputSchema for %s", tool.Name) + continue + } + + require.NotNil(t, tool.OutputSchema, "%s must publish its typed output schema", tool.Name) + require.NoError(t, toolsnaps.Test(tool.Name+"_output", mcp.Tool{ + Name: tool.Name, + OutputSchema: tool.OutputSchema, + })) + schemaJSON, err := json.Marshal(tool.OutputSchema) + require.NoError(t, err) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal(schemaJSON, &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + require.NoError(t, resolved.Validate(projectedReadOutputSample(tool.Name)), "%s output must conform to its schema", tool.Name) + require.NoError(t, resolved.Validate(projectedReadNullOutputSample(tool.Name)), "%s output schema must accept nullable optional fields", tool.Name) + outputSchemas[tool.Name] = resolved + } + + branches, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "list_branches", + Arguments: map[string]any{"owner": "owner", "repo": "repo"}, + }) + require.NoError(t, err) + require.False(t, branches.IsError, branches) + assert.Equal(t, "[]", getTextResult(t, branches).Text) + assert.JSONEq(t, mustMarshalJSON(t, ifc.LabelRepoMetadata(false)), mustMarshalJSON(t, branches.Meta["ifc"])) + if protocolVersion == inventory.ProtocolVersionMultiRoundTrip { + assert.Equal(t, "[]", mustMarshalJSON(t, branches.StructuredContent)) + require.NoError(t, outputSchemas["list_branches"].Validate(branches.StructuredContent)) + } else { + assert.Nil(t, branches.StructuredContent) + } + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "list_tags", + Arguments: map[string]any{"owner": "owner", "repo": "repo"}, + }) + require.NoError(t, err) + require.False(t, result.IsError, result) + require.Equal(t, `[{"name":"v1.0.0","sha":"abc123"}]`, getTextResult(t, result).Text) + assert.JSONEq(t, mustMarshalJSON(t, ifc.LabelRepoMetadata(false)), mustMarshalJSON(t, result.Meta["ifc"])) + if protocolVersion == "2025-11-25" { + assert.Nil(t, result.StructuredContent) + } else { + structured, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, getTextResult(t, result).Text, string(structured)) + require.NoError(t, outputSchemas["list_tags"].Validate(result.StructuredContent)) + } + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "search_code", + Arguments: map[string]any{ + "query": "main.go", + "fields": []any{"name"}, + }, + }) + require.NoError(t, err) + require.False(t, result.IsError, result) + if protocolVersion == "2025-11-25" { + assert.Equal(t, `{"incomplete_results":false,"items":[{"name":"main.go"}],"total_count":1}`, getTextResult(t, result).Text) + } + assert.JSONEq(t, mustMarshalJSON(t, ifc.LabelSearchIssues([]bool{false})), mustMarshalJSON(t, result.Meta["ifc"])) + if protocolVersion == "2025-11-25" { + assert.Nil(t, result.StructuredContent) + } else { + assert.JSONEq(t, `{"total_count":1,"incomplete_results":false,"items":[{"name":"main.go"}]}`, getTextResult(t, result).Text) + structured, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, getTextResult(t, result).Text, string(structured)) + require.NoError(t, outputSchemas["search_code"].Validate(result.StructuredContent)) + } + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "search_code", + Arguments: map[string]any{"query": "main.go"}, + }) + require.NoError(t, err) + require.False(t, result.IsError, result) + if protocolVersion == "2025-11-25" { + assert.Equal(t, `{"total_count":1,"incomplete_results":false,"items":[{"name":"main.go","path":"cmd/main.go","sha":"abc123","repository":"owner/repo","text_matches":[{"object_type":"FileContent","property":"content","fragment":"func main()","matches":[{"text":"main","indices":[5,9]}]}]}]}`, getTextResult(t, result).Text) + } + if protocolVersion == inventory.ProtocolVersionMultiRoundTrip { + assert.JSONEq(t, `{"total_count":1,"incomplete_results":false,"items":[{"name":"main.go","path":"cmd/main.go","sha":"abc123","repository":"owner/repo","text_matches":[{"object_type":"FileContent","property":"content","fragment":"func main()","matches":[{"text":"main","indices":[5,9]}]}]}]}`, getTextResult(t, result).Text) + require.NoError(t, outputSchemas["search_code"].Validate(result.StructuredContent)) + assert.JSONEq(t, getTextResult(t, result).Text, mustMarshalJSON(t, result.StructuredContent)) + } + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "search_issues", + Arguments: map[string]any{"query": "bug"}, + }) + require.NoError(t, err) + require.False(t, result.IsError, result) + if protocolVersion == "2025-11-25" { + assert.Equal(t, `{"total_count":1,"incomplete_results":false,"items":[{"body":"A body","id":42,"number":42,"state":"open","title":"A title","url":"https://api.github.com/repos/owner/repo/issues/42"}]}`, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } else { + assert.JSONEq(t, `{"total_count":1,"incomplete_results":false,"items":[{"number":42,"title":"A title","body":"A body","state":"open"}]}`, getTextResult(t, result).Text) + assert.JSONEq(t, getTextResult(t, result).Text, mustMarshalJSON(t, result.StructuredContent)) + require.NoError(t, outputSchemas["search_issues"].Validate(result.StructuredContent)) + } + + failure, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "search_code", + Arguments: map[string]any{"query": ""}, + }) + require.NoError(t, err) + require.True(t, failure.IsError) + assert.Nil(t, failure.StructuredContent, "error results must not expose typed output") + assert.Equal(t, "missing required parameter: query", getErrorResult(t, failure).Text) + }) + } +} + +func projectedReadOutputSample(name string) any { + switch name { + case "search_code": + return map[string]any{ + "total_count": 1, "incomplete_results": false, + "items": []any{map[string]any{ + "name": "main.go", "path": "cmd/main.go", "sha": "abc123", "repository": "owner/repo", + "text_matches": []any{map[string]any{ + "object_url": "https://api.github.com/repos/owner/repo", + "object_type": "FileContent", "property": "content", "fragment": "func main()", + "matches": []any{map[string]any{"text": "main", "indices": []any{5, 9}}}, + }}, + }}, + } + case "search_issues", "search_pull_requests": + return map[string]any{ + "total_count": 1, "incomplete_results": false, + "items": []any{map[string]any{ + "number": 42, "title": "A title", "body": "A body", "state": "open", + "state_reason": "completed", "draft": false, "locked": false, + "html_url": "https://github.com/owner/repo/issues/42", + "user": map[string]any{"login": "octocat", "profile_url": "https://github.com/octocat"}, + "author_association": "OWNER", "labels": []any{"bug"}, + "assignee": "octocat", "assignees": []any{"octocat"}, "milestone": "v1", + "comments": 3, + "reactions": map[string]any{ + "total_count": 1, "+1": 1, "-1": 0, "laugh": 0, "confused": 0, + "heart": 0, "hooray": 0, "rocket": 0, "eyes": 0, + }, + "created_at": "2026-01-02T03:04:05Z", "updated_at": "2026-01-03T03:04:05Z", + "closed_at": "2026-01-04T03:04:05Z", "closed_by": "maintainer", + "type": "Bug", "repository_url": "https://api.github.com/repos/owner/repo", + "pull_request": map[string]any{ + "url": "https://api.github.com/repos/owner/repo/pulls/42", + "html_url": "https://github.com/owner/repo/pull/42", + "diff_url": "https://github.com/owner/repo/pull/42.diff", + "patch_url": "https://github.com/owner/repo/pull/42.patch", + }, + "field_values": []any{map[string]any{"field": "Priority", "value": "P1"}}, + }}, + } + case "list_issues": + return map[string]any{ + "issues": []any{map[string]any{ + "number": 42, "title": "A title", "body": "A body", "state": "OPEN", + "user": map[string]any{"login": "octocat"}, "labels": []any{"bug"}, + "assignees": []any{"octocat"}, "comments": 3, + "created_at": "2026-01-02T03:04:05Z", "updated_at": "2026-01-03T03:04:05Z", + "field_values": []any{map[string]any{"field": "Priority", "value": "P1"}}, + }}, + "totalCount": 1, + "pageInfo": map[string]any{ + "hasNextPage": false, "hasPreviousPage": false, + "startCursor": "start", "endCursor": "end", + }, + } + case "list_pull_requests": + return []any{map[string]any{ + "number": 42, "title": "A title", "body": "A body", "state": "open", + "draft": false, "merged": false, "mergeable_state": "clean", + "html_url": "https://github.com/owner/repo/pull/42", + "user": map[string]any{"login": "octocat"}, + "labels": []any{"bug"}, "assignees": []any{"octocat"}, + "requested_reviewers": []any{"reviewer"}, "merged_by": "maintainer", + "head": map[string]any{"ref": "feature", "sha": "abc123"}, + "base": map[string]any{"ref": "main", "sha": "def456"}, + "additions": 5, "deletions": 2, "changed_files": 1, "commits": 1, + "comments": 3, "created_at": "2026-01-02T03:04:05Z", + "updated_at": "2026-01-03T03:04:05Z", "closed_at": "2026-01-04T03:04:05Z", + "merged_at": "2026-01-04T03:04:05Z", "milestone": "v1", + }} + case "list_branches": + return []any{map[string]any{"name": "main", "sha": "abc123", "protected": true}} + case "list_tags": + return []any{map[string]any{"name": "v1.0.0", "sha": "abc123"}} + default: + panic("unexpected projected read tool: " + name) + } +} + +func projectedReadNullOutputSample(name string) any { + switch name { + case "search_code": + return map[string]any{ + "total_count": 0, "incomplete_results": false, + "items": []any{map[string]any{"name": nil}}, + } + case "search_issues", "search_pull_requests": + return map[string]any{ + "total_count": nil, "incomplete_results": nil, + "items": []any{map[string]any{"title": nil}}, + } + case "list_issues": + return map[string]any{ + "issues": []any{map[string]any{"state": nil}}, "totalCount": 0, + "pageInfo": map[string]any{"hasNextPage": false, "hasPreviousPage": false}, + } + case "list_pull_requests": + return []any{map[string]any{"state": nil}} + case "list_branches", "list_tags": + return []any{} + default: + panic("unexpected projected read tool: " + name) + } +} + +func TestTypedProjectedReadOutputsRespectFieldSelection(t *testing.T) { + code := structuredSearchCodeOutput(MinimalCodeSearchResult{ + TotalCount: 1, + IncompleteResults: false, + Items: []MinimalCodeResult{{Name: "main.go", Path: "cmd/main.go", SHA: "abc123", Repository: "owner/repo"}}, + }, []string{"name"}) + assert.JSONEq(t, `{"total_count":1,"incomplete_results":false,"items":[{"name":"main.go"}]}`, mustMarshalJSON(t, code)) + + searchIssues := structuredSearchIssuesOutput(SearchIssuesResponse{ + Total: new(1), + IncompleteResults: new(false), + Items: []SearchIssueResult{{ + Issue: &github.Issue{Number: new(42), Title: new("A title"), Body: new("A body")}, + FieldValues: []MinimalFieldValue{}, + }}, + }, []string{"title"}) + assert.JSONEq(t, `{"total_count":1,"incomplete_results":false,"items":[{"title":"A title"}]}`, mustMarshalJSON(t, searchIssues)) + + emptyFieldValues := structuredSearchIssuesOutput(SearchIssuesResponse{ + Items: []SearchIssueResult{{Issue: &github.Issue{}, FieldValues: []MinimalFieldValue{}}}, + }, []string{"field_values"}) + assert.Equal(t, `{"items":[{"field_values":[]}]}`, mustMarshalJSON(t, emptyFieldValues)) + + unavailableFieldValues := structuredSearchIssuesOutput(SearchIssuesResponse{ + Items: []SearchIssueResult{{Issue: &github.Issue{}}}, + }, []string{"field_values"}) + assert.Equal(t, `{"items":[{}]}`, mustMarshalJSON(t, unavailableFieldValues)) + + codeMatches := structuredSearchCodeOutput(MinimalCodeSearchResult{ + Items: []MinimalCodeResult{{ + TextMatches: []*github.TextMatch{{ + ObjectURL: new("https://api.github.com/repos/owner/repo"), + ObjectType: new("FileContent"), + Property: new("content"), + Fragment: new("func main()"), + Matches: []*github.Match{{ + Text: new("main"), + Indices: []int{5, 9}, + }}, + }}, + }}, + }, []string{"text_matches"}) + assert.Equal(t, `{"total_count":0,"incomplete_results":false,"items":[{"text_matches":[{"object_url":"https://api.github.com/repos/owner/repo","object_type":"FileContent","property":"content","fragment":"func main()","matches":[{"text":"main","indices":[5,9]}]}]}]}`, mustMarshalJSON(t, codeMatches)) + + issues := structuredListIssuesOutput(MinimalIssuesResponse{ + Issues: []MinimalIssue{{Number: 42, Title: "A title", Body: "A body", State: "OPEN", Assignees: []string{}}}, + TotalCount: 1, + }, []string{"title"}) + assert.JSONEq(t, `{"issues":[{"title":"A title"}],"totalCount":1,"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`, mustMarshalJSON(t, issues)) + + pullRequests := structuredListPullRequestsOutput([]MinimalPullRequest{{ + Number: 42, Title: "A title", Body: "A body", State: "open", HTMLURL: "https://example.test/pr/42", + }}, []string{"title"}) + assert.JSONEq(t, `[{"title":"A title"}]`, mustMarshalJSON(t, pullRequests)) +} + +func TestNormalizeTypedReadArgumentsPreservesLegacyValues(t *testing.T) { + normalize := normalizeTypedReadArguments([]string{"state", "orderBy", "direction"}, true) + normalized, err := normalize(json.RawMessage(`{"state":"open","orderBy":"updated_at","direction":"asc","perPage":"25","page":null}`)) + require.NoError(t, err) + assert.JSONEq(t, `{"state":"OPEN","orderBy":"UPDATED_AT","direction":"ASC","perPage":25,"page":null}`, string(normalized)) +} + +func mustMarshalJSON(t *testing.T, value any) string { + t.Helper() + encoded, err := json.Marshal(value) + require.NoError(t, err) + return string(encoded) +} diff --git a/pkg/github/typed_security_compatibility_test.go b/pkg/github/typed_security_compatibility_test.go new file mode 100644 index 0000000000..994aacb69e --- /dev/null +++ b/pkg/github/typed_security_compatibility_test.go @@ -0,0 +1,213 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "testing" + + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSecurityRegisteredFilters(t *testing.T) { + tests := []struct { + name string + tool inventory.ServerTool + route string + arguments map[string]any + wantQuery url.Values + }{ + { + name: "dependabot omitted state", + tool: ListDependabotAlerts(translations.NullTranslationHelper), route: GetReposDependabotAlertsByOwnerByRepo, + arguments: map[string]any{"owner": "owner", "repo": "repo"}, + wantQuery: url.Values{"per_page": {"30"}}, + }, + { + name: "dependabot explicit state", + tool: ListDependabotAlerts(translations.NullTranslationHelper), route: GetReposDependabotAlertsByOwnerByRepo, + arguments: map[string]any{"owner": "owner", "repo": "repo", "state": "fixed"}, + wantQuery: url.Values{"per_page": {"30"}, "state": {"fixed"}}, + }, + { + name: "dependabot omitted state with harness page size", + tool: ListDependabotAlerts(translations.NullTranslationHelper), + route: GetReposDependabotAlertsByOwnerByRepo, + arguments: map[string]any{"owner": "owner", "repo": "repo", "perPage": 2}, + wantQuery: url.Values{"per_page": {"2"}}, + }, + { + name: "code scanning omitted state", + tool: ListCodeScanningAlerts(translations.NullTranslationHelper), route: GetReposCodeScanningAlertsByOwnerByRepo, + arguments: map[string]any{"owner": "owner", "repo": "repo"}, + wantQuery: url.Values{"page": {"1"}, "per_page": {"30"}}, + }, + { + name: "advisories omitted type", + tool: ListGlobalSecurityAdvisories(translations.NullTranslationHelper), route: GetAdvisories, + arguments: map[string]any{}, + wantQuery: url.Values{}, + }, + { + name: "advisories null cwes", + tool: ListGlobalSecurityAdvisories(translations.NullTranslationHelper), route: GetAdvisories, + arguments: map[string]any{"type": "reviewed", "cwes": nil}, + wantQuery: url.Values{"type": {"reviewed"}}, + }, + { + name: "advisories explicit cwes", + tool: ListGlobalSecurityAdvisories(translations.NullTranslationHelper), route: GetAdvisories, + arguments: map[string]any{"type": "reviewed", "cwes": []string{"79", "284"}}, + wantQuery: url.Values{"type": {"reviewed"}, "cwes": {"79", "284"}}, + }, + } + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + for _, tt := range tests { + t.Run(protocol+"/"+tt.name, func(t *testing.T) { + called := false + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + tt.route: func(w http.ResponseWriter, r *http.Request) { + called = true + assert.Equal(t, tt.wantQuery, r.URL.Query(), "SDK validation must not insert advertised defaults into omitted filters") + mockResponse(t, http.StatusOK, []any{})(w, r) + }, + }))} + server := mcp.NewServer(&mcp.Implementation{Name: "security-filters-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + tt.tool.RegisterFunc(server, deps) + session := connectCommentVisibilityClient(t, server, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tt.tool.Tool.Name, Arguments: tt.arguments}) + require.NoError(t, err) + require.False(t, result.IsError, "%s", getTextResult(t, result).Text) + assert.True(t, called, "the API must receive the compatible filter") + }) + } + } +} + +func TestSecurityEmptyAndErrorOutputs(t *testing.T) { + tests := []struct { + tool inventory.ServerTool + route string + arguments map[string]any + list bool + }{ + {GetCodeQualityFinding(translations.NullTranslationHelper), GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber, map[string]any{"owner": "owner", "repo": "repo", "findingNumber": 42}, false}, + {GetCodeScanningAlert(translations.NullTranslationHelper), GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber, map[string]any{"owner": "owner", "repo": "repo", "alertNumber": 42}, false}, + {ListCodeScanningAlerts(translations.NullTranslationHelper), GetReposCodeScanningAlertsByOwnerByRepo, map[string]any{"owner": "owner", "repo": "repo"}, true}, + {GetSecretScanningAlert(translations.NullTranslationHelper), GetReposSecretScanningAlertsByOwnerByRepoByAlertNumber, map[string]any{"owner": "owner", "repo": "repo", "alertNumber": 42}, false}, + {ListSecretScanningAlerts(translations.NullTranslationHelper), GetReposSecretScanningAlertsByOwnerByRepo, map[string]any{"owner": "owner", "repo": "repo"}, true}, + {GetDependabotAlert(translations.NullTranslationHelper), GetReposDependabotAlertsByOwnerByRepoByAlertNumber, map[string]any{"owner": "owner", "repo": "repo", "alertNumber": 42}, false}, + {ListDependabotAlerts(translations.NullTranslationHelper), GetReposDependabotAlertsByOwnerByRepo, map[string]any{"owner": "owner", "repo": "repo"}, true}, + {GetGlobalSecurityAdvisory(translations.NullTranslationHelper), GetAdvisoriesByGhsaID, map[string]any{"ghsaId": "GHSA-aaaa-bbbb-cccc"}, false}, + {ListGlobalSecurityAdvisories(translations.NullTranslationHelper), GetAdvisories, map[string]any{}, true}, + {ListRepositorySecurityAdvisories(translations.NullTranslationHelper), GetReposSecurityAdvisoriesByOwnerByRepo, map[string]any{"owner": "owner", "repo": "repo"}, true}, + {ListOrgRepositorySecurityAdvisories(translations.NullTranslationHelper), GetOrgsSecurityAdvisoriesByOrg, map[string]any{"org": "owner"}, true}, + } + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + for _, tt := range tests { + for _, body := range []string{"null", "[]", "[null]", `{"message":"access denied"}`} { + isError := body == `{"message":"access denied"}` + if !tt.list && !isError && body != "null" { + continue + } + t.Run(protocol+"/"+tt.tool.Tool.Name+"/"+body, func(t *testing.T) { + status := http.StatusOK + if isError { + status = http.StatusForbidden + } + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + tt.route: mockResponse(t, status, json.RawMessage(body)), + }))} + server := mcp.NewServer(&mcp.Implementation{Name: "security-empty-test", Version: "v1"}, nil) + var serializedResult json.RawMessage + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + result, err := next(ctx, method, request) + if method == "tools/call" && err == nil { + serializedResult, err = json.Marshal(result) + } + return result, err + } + }) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + tt.tool.RegisterFunc(server, deps) + session := connectCommentVisibilityClient(t, server, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tt.tool.Tool.Name, Arguments: tt.arguments}) + require.NoError(t, err) + require.Equal(t, isError, result.IsError, "%s", getTextResult(t, result).Text) + if isError { + assert.Nil(t, result.StructuredContent, "errors must not produce success-shaped structured output") + assert.Contains(t, getTextResult(t, result).Text, "access denied") + return + } + if protocol == "2025-11-25" { + if tt.tool.Tool.Name == "list_dependabot_alerts" { + assert.JSONEq(t, `{"alerts":`+body+`,"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`, getTextResult(t, result).Text) + } else { + assert.Equal(t, body, getTextResult(t, result).Text) + } + assert.Nil(t, result.StructuredContent) + return + } + schema, err := securityOutputSchema(t, tt.tool.Tool.Name).Resolve(nil) + require.NoError(t, err) + var wireFields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(serializedResult, &wireFields)) + wireStructured, present := wireFields["structuredContent"] + require.True(t, present, "successful schema-bearing tools must serialize structuredContent, including explicit JSON null") + var structured any + require.NoError(t, json.Unmarshal(wireStructured, &structured)) + require.NoError(t, schema.Validate(structured)) + if !tt.list { + expected := "null" + if tt.tool.Tool.Name == "get_code_quality_finding" { + expected = "{}" + } + assert.JSONEq(t, expected, string(wireStructured)) + assert.JSONEq(t, string(wireStructured), getTextResult(t, result).Text) + } + if tt.list { + assert.JSONEq(t, mustMarshalJSON(t, result.StructuredContent), getTextResult(t, result).Text, "modern lists expose the same DTO through both channels") + if tt.tool.Tool.Name == "list_dependabot_alerts" { + assert.JSONEq(t, getTextResult(t, result).Text, mustMarshalJSON(t, result.StructuredContent)) + } else { + assert.Equal(t, body, mustMarshalJSON(t, result.StructuredContent)) + } + } + }) + } + } + } +} + +func TestLegacyCodeQualityFindingFormatting(t *testing.T) { + raw := json.RawMessage(`{"z":1.0,"number":42,"rule":{"z":"last","a":"first"},"unknown":{"n":1e3},"a":null}`) + formatted, err := marshalLegacyCodeQualityFinding(raw) + require.NoError(t, err) + assert.Equal(t, `{"a":null,"number":42,"rule":{"a":"first","z":"last"},"unknown":{"n":1000},"z":1}`, string(formatted)) +} + +func TestSecurityInputSchemasPreserveEnums(t *testing.T) { + for _, tool := range []inventory.ServerTool{ + ListCodeScanningAlerts(translations.NullTranslationHelper), + ListDependabotAlerts(translations.NullTranslationHelper), + ListGlobalSecurityAdvisories(translations.NullTranslationHelper), + } { + schema := tool.Tool.InputSchema.(*jsonschema.Schema) + property := "state" + expectedDefault := `"open"` + if tool.Tool.Name == "list_global_security_advisories" { + property, expectedDefault = "type", `"reviewed"` + } + require.NotEmpty(t, schema.Properties[property].Enum) + assert.Equal(t, expectedDefault, string(schema.Properties[property].Default)) + assert.NotEmpty(t, schema.Properties[property].Description) + } +} diff --git a/pkg/github/typed_security_outputs_test.go b/pkg/github/typed_security_outputs_test.go new file mode 100644 index 0000000000..414e8cb7a8 --- /dev/null +++ b/pkg/github/typed_security_outputs_test.go @@ -0,0 +1,392 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "reflect" + "testing" + + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func securityOutputSchema(t *testing.T, name string) *jsonschema.Schema { + t.Helper() + var schema *jsonschema.Schema + var err error + switch name { + case "get_code_quality_finding": + schema, err = jsonschema.For[*CodeQualityFindingOutput](nil) + case "get_code_scanning_alert": + schema, err = jsonschema.For[*CodeScanningAlertOutput](nil) + case "list_code_scanning_alerts": + schema, err = jsonschema.For[[]*CodeScanningAlertOutput](nil) + case "get_secret_scanning_alert": + schema, err = jsonschema.For[*SecretScanningAlertOutput](nil) + case "list_secret_scanning_alerts": + schema, err = jsonschema.For[[]*SecretScanningAlertOutput](nil) + case "get_dependabot_alert": + schema, err = jsonschema.For[*DependabotAlertOutput](nil) + case "list_dependabot_alerts": + schema, err = jsonschema.For[DependabotAlertsOutput](nil) + case "get_global_security_advisory": + schema, err = jsonschema.For[*GlobalSecurityAdvisoryOutput](nil) + case "list_global_security_advisories": + schema, err = jsonschema.For[[]*GlobalSecurityAdvisoryOutput](nil) + case "list_repository_security_advisories", "list_org_repository_security_advisories": + schema, err = jsonschema.For[[]*SecurityAdvisoryOutput](nil) + default: + t.Fatalf("unexpected security tool %s", name) + } + require.NoError(t, err) + return schema +} + +func testSecurityToolSnapshot(t *testing.T, tool mcp.Tool) { + t.Helper() + tool.OutputSchema = securityOutputSchema(t, tool.Name) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) +} + +func TestTypedSecurityToolOutputs(t *testing.T) { + codeQualityFinding := map[string]any{ + "number": 42, + "state": "open", + "rule": map[string]any{ + "id": "test-rule", + "description": "Test rule", + }, + "unknown_legacy_field": "preserved in text", + "url": "https://api.github.com/repos/owner/repo/code-quality/findings/42", + } + codeScanningAlert := &github.Alert{ + Number: new(42), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/security/code-scanning/42"), + URL: new("https://api.github.com/repos/owner/repo/code-scanning/alerts/42"), + Rule: &github.Rule{ + ID: new("test-rule"), + Description: new("Test rule"), + FullDescription: new("Full rule description"), + Help: new("Use parameterized queries"), + SecuritySeverityLevel: new("high"), + }, + } + secretScanningAlert := &github.SecretScanningAlert{ + Number: new(43), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/security/secret-scanning/43"), + SecretType: new("test_secret"), + Secret: new("test-secret"), + URL: new("https://api.github.com/repos/owner/repo/secret-scanning/alerts/43"), + Validity: new("active"), + ResolutionComment: new("Rotate this credential"), + PushProtectionBypassed: new(true), + IsBase64Encoded: new(true), + HasMoreLocations: new(true), + FirstLocationDetected: &github.SecretScanningAlertLocationDetails{ + Path: new("config.env"), CommitSHA: new("abc123"), StartColumn: new(5), + CommitURL: new("https://api.github.com/repos/owner/repo/commits/abc123"), + }, + PushProtectionBypassRequestComment: new("legacy response detail"), + } + dependabotAlert := &github.DependabotAlert{ + Number: new(44), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/security/dependabot/44"), + URL: new("https://api.github.com/repos/owner/repo/dependabot/alerts/44"), + DismissedReason: new("tolerable_risk"), + SecurityVulnerability: &github.AdvisoryVulnerability{ + VulnerableVersionRange: new("< 2.0"), + FirstPatchedVersion: &github.FirstPatchedVersion{Identifier: new("2.0")}, + }, + SecurityAdvisory: &github.DependabotSecurityAdvisory{ + GHSAID: new("GHSA-aaaa-bbbb-cccc"), + Classification: new("malware"), + Vulnerabilities: []*github.AdvisoryVulnerability{{VulnerableVersionRange: new("< 2.0")}}, + }, + } + advisory := &github.SecurityAdvisory{ + GHSAID: new("GHSA-aaaa-bbbb-cccc"), + Summary: new("Test advisory"), + Description: new("Test advisory description"), + Severity: new("high"), + State: new("published"), + URL: new("https://api.github.com/repos/owner/repo/security-advisories/GHSA-aaaa-bbbb-cccc"), + Vulnerabilities: []*github.AdvisoryVulnerability{{ + PatchedVersions: new("2.0"), VulnerableVersionRange: new("< 2.0"), + VulnerableFunctions: []string{"unsafeQuery"}, + }}, + } + globalAdvisory := &github.GlobalSecurityAdvisory{ + SecurityAdvisory: *advisory, + RepositoryAdvisoryURL: new("https://api.github.com/repos/owner/repo/security-advisories/GHSA-aaaa-bbbb-cccc"), + Vulnerabilities: []*github.GlobalSecurityVulnerability{{ + FirstPatchedVersion: new("2.0"), VulnerableVersionRange: new("< 2.0"), + VulnerableFunctions: []string{"unsafeQuery"}, + }}, + } + globalAdvisory.CWEs = []*github.AdvisoryCWEs{{CWEID: new("CWE-79"), Name: new("Cross-site scripting")}} + codeQualityJSON := json.RawMessage(`{"unknown_legacy_field":"preserved in text","url":"https://api.github.com/repos/owner/repo/code-quality/findings/42","state":"open","rule":{"id":"test-rule","description":"Test rule"},"number":42}`) + legacyOutputs := map[string]any{ + "get_code_quality_finding": codeQualityFinding, + "get_code_scanning_alert": codeScanningAlert, + "list_code_scanning_alerts": []*github.Alert{codeScanningAlert}, + "get_secret_scanning_alert": secretScanningAlert, + "list_secret_scanning_alerts": []*github.SecretScanningAlert{secretScanningAlert}, + "get_dependabot_alert": dependabotAlert, + "list_dependabot_alerts": struct { + Alerts []*github.DependabotAlert `json:"alerts"` + PageInfo pageInfo `json:"pageInfo"` + }{Alerts: []*github.DependabotAlert{dependabotAlert}}, + "get_global_security_advisory": globalAdvisory, + "list_global_security_advisories": []*github.GlobalSecurityAdvisory{globalAdvisory}, + "list_repository_security_advisories": []*github.SecurityAdvisory{advisory}, + "list_org_repository_security_advisories": []*github.SecurityAdvisory{advisory}, + } + + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber: mockResponse(t, http.StatusOK, codeQualityJSON), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{Private: new(true)}), + GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber: mockResponse(t, http.StatusOK, codeScanningAlert), + GetReposCodeScanningAlertsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.Alert{codeScanningAlert}), + GetReposSecretScanningAlertsByOwnerByRepoByAlertNumber: mockResponse(t, http.StatusOK, secretScanningAlert), + GetReposSecretScanningAlertsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.SecretScanningAlert{secretScanningAlert}), + GetReposDependabotAlertsByOwnerByRepoByAlertNumber: mockResponse(t, http.StatusOK, dependabotAlert), + GetReposDependabotAlertsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.DependabotAlert{dependabotAlert}), + GetAdvisories: mockResponse(t, http.StatusOK, []*github.GlobalSecurityAdvisory{globalAdvisory}), + GetAdvisoriesByGhsaID: mockResponse(t, http.StatusOK, globalAdvisory), + GetReposSecurityAdvisoriesByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{advisory}), + GetOrgsSecurityAdvisoriesByOrg: mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{advisory}), + })), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + tools := []inventory.ServerTool{ + GetCodeQualityFinding(translations.NullTranslationHelper), + GetCodeScanningAlert(translations.NullTranslationHelper), + ListCodeScanningAlerts(translations.NullTranslationHelper), + GetSecretScanningAlert(translations.NullTranslationHelper), + ListSecretScanningAlerts(translations.NullTranslationHelper), + GetDependabotAlert(translations.NullTranslationHelper), + ListDependabotAlerts(translations.NullTranslationHelper), + ListGlobalSecurityAdvisories(translations.NullTranslationHelper), + GetGlobalSecurityAdvisory(translations.NullTranslationHelper), + ListRepositorySecurityAdvisories(translations.NullTranslationHelper), + ListOrgRepositorySecurityAdvisories(translations.NullTranslationHelper), + } + calls := []mcp.CallToolParams{ + {Name: "get_code_quality_finding", Arguments: map[string]any{"owner": "owner", "repo": "repo", "findingNumber": "42"}}, + {Name: "get_code_scanning_alert", Arguments: map[string]any{"owner": "owner", "repo": "repo", "alertNumber": "42"}}, + {Name: "list_code_scanning_alerts", Arguments: map[string]any{"owner": "owner", "repo": "repo", "page": "1", "perPage": "30"}}, + {Name: "get_secret_scanning_alert", Arguments: map[string]any{"owner": "owner", "repo": "repo", "alertNumber": "43"}}, + {Name: "list_secret_scanning_alerts", Arguments: map[string]any{"owner": "owner", "repo": "repo", "page": "1", "perPage": "30"}}, + {Name: "get_dependabot_alert", Arguments: map[string]any{"owner": "owner", "repo": "repo", "alertNumber": "44"}}, + {Name: "list_dependabot_alerts", Arguments: map[string]any{"owner": "owner", "repo": "repo", "perPage": "30"}}, + {Name: "list_global_security_advisories", Arguments: map[string]any{"type": "reviewed", "cwes": []string{"79"}, "unknown_legacy_field": true}}, + {Name: "get_global_security_advisory", Arguments: map[string]any{"ghsaId": "GHSA-aaaa-bbbb-cccc"}}, + {Name: "list_repository_security_advisories", Arguments: map[string]any{"owner": "owner", "repo": "repo"}}, + {Name: "list_org_repository_security_advisories", Arguments: map[string]any{"org": "owner"}}, + } + legacyIFC := make(map[string]string) + t.Run("unknown direct caller", func(t *testing.T) { + for i, tool := range tools { + call := calls[i] + arguments, err := json.Marshal(call.Arguments) + require.NoError(t, err) + result, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{Name: call.Name, Arguments: arguments}, + }) + require.NoError(t, err, call.Name) + require.False(t, result.IsError, call.Name) + assert.Nil(t, result.StructuredContent, "unknown callers must remain text-only") + assert.Equal(t, mustMarshalJSON(t, legacyOutputs[call.Name]), getTextResult(t, result).Text, call.Name) + } + }) + + for _, protocolVersion := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocolVersion, func(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "typed-security-output-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + for _, tool := range tools { + tool.RegisterFunc(server, deps) + } + + session := connectCommentVisibilityClient(t, server, protocolVersion) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + toolsByName := make(map[string]*mcp.Tool, len(list.Tools)) + for _, tool := range list.Tools { + toolsByName[tool.Name] = tool + if protocolVersion == "2025-11-25" { + assert.Nil(t, tool.OutputSchema, "legacy clients must not see outputSchema for %s", tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, "%s must publish an output schema", tool.Name) + schemaBytes, err := json.Marshal(tool.OutputSchema) + require.NoError(t, err) + t.Logf("%s output schema: %d bytes", tool.Name, len(schemaBytes)) + assert.JSONEq(t, mustMarshalJSON(t, securityOutputSchema(t, tool.Name)), string(schemaBytes), "canonical snapshots must describe the modern wire schema") + } + + for _, call := range calls { + result, err := session.CallTool(context.Background(), &call) + require.NoError(t, err, call.Name) + require.False(t, result.IsError, "%s: %s", call.Name, result) + text := getTextResult(t, result).Text + if protocolVersion == "2025-11-25" { + assert.Equal(t, mustMarshalJSON(t, legacyOutputs[call.Name]), text, "%s must retain byte-exact main serialization for legacy clients", call.Name) + legacyIFC[call.Name] = mustMarshalJSON(t, result.Meta["ifc"]) + assert.Nil(t, result.StructuredContent, "%s must remain text-only for legacy clients", call.Name) + continue + } + + require.NotNil(t, result.StructuredContent, "%s must return structured content", call.Name) + assert.Equal(t, legacyIFC[call.Name], mustMarshalJSON(t, result.Meta["ifc"]), "IFC labels must protect text and structured content identically") + if call.Name != "get_code_quality_finding" { + assert.NotNil(t, result.Meta["ifc"], "%s must retain its IFC label", call.Name) + } + schemaJSON, err := json.Marshal(toolsByName[call.Name].OutputSchema) + require.NoError(t, err) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal(schemaJSON, &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + structuredJSON, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, string(structuredJSON), text, "modern JSON text must expose the same compact DTO as structuredContent") + var structured any + require.NoError(t, json.Unmarshal(structuredJSON, &structured)) + require.NoError(t, resolved.Validate(structured), "%s output must conform to its schema", call.Name) + assert.NotContains(t, string(structuredJSON), "https://api.github.com", "API/hypermedia URLs must remain text-only") + switch call.Name { + case "get_code_scanning_alert", "list_code_scanning_alerts": + assert.Contains(t, string(structuredJSON), "Use parameterized queries") + assert.Contains(t, string(structuredJSON), `"security_severity_level":"high"`) + case "get_secret_scanning_alert", "list_secret_scanning_alerts": + assert.Contains(t, string(structuredJSON), `"is_base64_encoded":true`) + assert.Contains(t, string(structuredJSON), `"has_more_locations":true`) + assert.Contains(t, text, `"secret":"test-secret"`) + assert.Contains(t, string(structuredJSON), `"secret":"test-secret"`, "preserve the same intentionally scoped secret as text") + assert.Contains(t, string(structuredJSON), `"validity":"active"`) + assert.Contains(t, string(structuredJSON), "Rotate this credential") + assert.Contains(t, string(structuredJSON), `"start_column":5`) + case "get_dependabot_alert", "list_dependabot_alerts": + assert.Contains(t, string(structuredJSON), `"classification":"malware"`) + assert.Contains(t, string(structuredJSON), `"first_patched_version":"2.0"`) + assert.Contains(t, string(structuredJSON), `"dismissed_reason":"tolerable_risk"`) + assert.NotContains(t, string(structuredJSON), `"vulnerabilities"`, "use the alert's affected package instead of repeating advisory-wide packages") + case "get_global_security_advisory", "list_global_security_advisories": + assert.Contains(t, string(structuredJSON), `"cwe_ids":["CWE-79"]`) + assert.Contains(t, string(structuredJSON), `"first_patched_version":"2.0"`) + assert.Contains(t, string(structuredJSON), "unsafeQuery") + case "list_repository_security_advisories", "list_org_repository_security_advisories": + assert.Contains(t, string(structuredJSON), `"patched_versions":"2.0"`) + assert.Contains(t, string(structuredJSON), "unsafeQuery") + } + + if call.Name == "get_code_quality_finding" { + assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "unknown_legacy_field") + } + + if call.Name == "get_code_scanning_alert" { + assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "full_description") + } + if call.Name == "get_secret_scanning_alert" { + assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "legacy response detail") + } + if call.Name == "get_code_scanning_alert" { + assert.NotNil(t, result.Meta["ifc"], "typed output must retain the security alert IFC label") + } + } + }) + } +} + +func TestGlobalSecurityAdvisoryCWEIDs(t *testing.T) { + for _, tc := range []struct { + name string + cweIDs []string + cwes []*github.AdvisoryCWEs + expected []string + }{ + {name: "absent"}, + {name: "global CWEs", cwes: []*github.AdvisoryCWEs{{CWEID: new("CWE-79")}, {CWEID: new("CWE-89")}}, expected: []string{"CWE-79", "CWE-89"}}, + {name: "missing CWE ID", cwes: []*github.AdvisoryCWEs{nil, {Name: new("No ID")}, {CWEID: new("CWE-79")}}, expected: []string{"CWE-79"}}, + {name: "explicit IDs", cweIDs: []string{"CWE-89"}, cwes: []*github.AdvisoryCWEs{{CWEID: new("CWE-79")}}, expected: []string{"CWE-89"}}, + {name: "empty IDs", cweIDs: []string{}, cwes: []*github.AdvisoryCWEs{{CWEID: new("CWE-79")}}, expected: []string{"CWE-79"}}, + } { + t.Run(tc.name, func(t *testing.T) { + advisory := github.GlobalSecurityAdvisory{ + SecurityAdvisory: github.SecurityAdvisory{CWEIDs: tc.cweIDs, CWEs: tc.cwes}, + } + assert.Equal(t, tc.expected, globalSecurityAdvisoryOutput(&advisory).CWEIDs) + assert.Equal(t, tc.cweIDs, advisory.CWEIDs, "projection must not mutate the API model") + }) + } +} + +func TestSecretScanningOutputQualifiers(t *testing.T) { + for _, tc := range []struct { + name string + value *bool + expected string + }{ + {"absent", nil, `{}`}, + {"false", new(false), `{"is_base64_encoded":false,"has_more_locations":false}`}, + {"true", new(true), `{"is_base64_encoded":true,"has_more_locations":true}`}, + } { + t.Run(tc.name, func(t *testing.T) { + output := secretScanningAlertOutput(&github.SecretScanningAlert{ + IsBase64Encoded: tc.value, HasMoreLocations: tc.value, + }) + assert.JSONEq(t, tc.expected, mustMarshalJSON(t, output)) + resolved, err := securityOutputSchema(t, "get_secret_scanning_alert").Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(tc.expected), &value)) + require.NoError(t, resolved.Validate(value)) + }) + } +} + +func TestSecurityOutputDTOs(t *testing.T) { + seen := make(map[reflect.Type]bool) + var check func(reflect.Type) + check = func(typ reflect.Type) { + if seen[typ] { + return + } + seen[typ] = true + assert.NotEqual(t, "github.com/google/go-github/v92/github", typ.PkgPath(), "%s must not embed raw API models", typ) + switch typ.Kind() { + case reflect.Pointer, reflect.Slice, reflect.Array: + check(typ.Elem()) + case reflect.Struct: + for field := range typ.Fields() { + if field.IsExported() { + check(field.Type) + } + } + case reflect.Map, reflect.Interface: + t.Errorf("%s must not use open-ended maps or interfaces", typ) + } + } + for _, typ := range []reflect.Type{ + reflect.TypeFor[CodeQualityFindingOutput](), + reflect.TypeFor[CodeScanningAlertOutput](), + reflect.TypeFor[SecretScanningAlertOutput](), + reflect.TypeFor[DependabotAlertsOutput](), + reflect.TypeFor[GlobalSecurityAdvisoryOutput](), + } { + check(typ) + } +} diff --git a/pkg/github/ui_capability.go b/pkg/github/ui_capability.go index 3de6a39b74..a46a8bb842 100644 --- a/pkg/github/ui_capability.go +++ b/pkg/github/ui_capability.go @@ -3,7 +3,7 @@ package github import ( "context" - ghcontext "github.com/github/github-mcp-server/pkg/context" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -62,16 +62,15 @@ func hasNonFormParams(args map[string]any, formParams map[string]struct{}) bool // shouldDeferToForm is the single source of truth for the show/defer decision // shared by the form-backed write tools (create_pull_request, // update_pull_request, issue_write). It reports whether a call should be handed -// off to its MCP App form instead of executing now: defer only when MCP Apps -// are enabled, form deferral has not been disabled, the client can render UI, +// off to its MCP App form instead of executing now: defer only when form +// deferral has not been disabled, the client can render UI, // the call is not itself a form submission, and every supplied parameter can // be represented by the form (formParams is the tool's form-parameter // allowlist). When it returns false the handler executes directly; the host may // still render the tool's view, which renders the result rather than an input // form. func shouldDeferToForm(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any, formParams map[string]struct{}) bool { - return deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && - !deps.IsFeatureEnabled(ctx, MCPAppsDisableFormDeferralFeatureFlag) && + return !deps.IsFeatureEnabled(ctx, MCPAppsDisableFormDeferralFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted(args) && !hasNonFormParams(args, formParams) diff --git a/pkg/github/ui_capability_test.go b/pkg/github/ui_capability_test.go index 1c49ee15be..02648361c0 100644 --- a/pkg/github/ui_capability_test.go +++ b/pkg/github/ui_capability_test.go @@ -4,7 +4,8 @@ import ( "context" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -95,31 +96,18 @@ func Test_shouldDeferToForm_featureFlags(t *testing.T) { tests := []struct { name string - enabledFlags []string + enabledFlags []inventory.FeatureFlag want bool }{ { - name: "MCP Apps enabled defers to form", - enabledFlags: []string{MCPAppsFeatureFlag}, - want: true, + name: "defers to form by default", + want: true, }, { - name: "form deferral disabled executes directly", - enabledFlags: []string{ - MCPAppsFeatureFlag, - MCPAppsDisableFormDeferralFeatureFlag, - }, - want: false, - }, - { - name: "form deferral opt-out does not enable MCP Apps", - enabledFlags: []string{MCPAppsDisableFormDeferralFeatureFlag}, + name: "form deferral disabled executes directly", + enabledFlags: []inventory.FeatureFlag{MCPAppsDisableFormDeferralFeatureFlag}, want: false, }, - { - name: "MCP Apps disabled executes directly", - want: false, - }, } for _, tc := range tests { diff --git a/pkg/github/ui_resources_test.go b/pkg/github/ui_resources_test.go index 49cce09bbd..9cc78ada5d 100644 --- a/pkg/github/ui_resources_test.go +++ b/pkg/github/ui_resources_test.go @@ -5,7 +5,7 @@ import ( "slices" "testing" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/ui_tools.go b/pkg/github/ui_tools.go index 62bba06ef6..84c6aae81f 100644 --- a/pkg/github/ui_tools.go +++ b/pkg/github/ui_tools.go @@ -9,11 +9,11 @@ import ( "sort" "strings" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v89/github" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -30,11 +30,12 @@ const uiGetMaxPages = 10 // UIGet creates a tool to fetch UI data for MCP Apps. func UIGet(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[map[string]any, *UIGetOutput]( ToolsetMetadataContext, // Use context toolset so it's always available mcp.Tool{ - Name: "ui_get", - Description: t("TOOL_UI_GET_DESCRIPTION", "Fetch UI data for MCP Apps (labels, assignees, milestones, issue types, branches, issue fields, reviewers)."), + Name: "ui_get", + OutputSchema: uiGetOutputSchema(), + Description: t("TOOL_UI_GET_DESCRIPTION", "Fetch UI data for MCP Apps (labels, assignees, milestones, issue types, branches, issue fields, reviewers)."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UI_GET_USER_TITLE", "Get UI data"), ReadOnlyHint: true, @@ -68,37 +69,40 @@ func UIGet(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, uiGetScopeAccess(), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, *UIGetOutput, error) { method, err := RequiredParam[string](args, "method") if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), &UIGetOutput{Method: "labels"}, nil } - owner, err := RequiredParam[string](args, "owner") if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), &UIGetOutput{Method: uiGetSchemaMethod(method)}, nil } + var result *mcp.CallToolResult switch method { case "labels": - return uiGetLabels(ctx, deps, args, owner) + result, _, err = uiGetLabels(ctx, deps, args, owner) case "assignees": - return uiGetAssignees(ctx, deps, args, owner) + result, _, err = uiGetAssignees(ctx, deps, args, owner) case "milestones": - return uiGetMilestones(ctx, deps, args, owner) + result, _, err = uiGetMilestones(ctx, deps, args, owner) case "issue_types": - return uiGetIssueTypes(ctx, deps, owner) + result, _, err = uiGetIssueTypes(ctx, deps, owner) case "branches": - return uiGetBranches(ctx, deps, args, owner) + result, _, err = uiGetBranches(ctx, deps, args, owner) case "issue_fields": - return uiGetIssueFields(ctx, deps, args, owner) + result, _, err = uiGetIssueFields(ctx, deps, args, owner) case "reviewers": - return uiGetReviewers(ctx, deps, args, owner) + result, _, err = uiGetReviewers(ctx, deps, args, owner) default: - return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), &UIGetOutput{Method: "labels"}, nil } - }) - st.FeatureFlagEnable = MCPAppsFeatureFlag + if err != nil { + return nil, nil, err + } + return uiGetTypedResult(method, result) + }, normalizeUIGetArguments) return st } diff --git a/pkg/github/ui_tools_test.go b/pkg/github/ui_tools_test.go index 4a4981875b..1a9eaf6a46 100644 --- a/pkg/github/ui_tools_test.go +++ b/pkg/github/ui_tools_test.go @@ -10,10 +10,11 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -105,7 +106,7 @@ func Test_UIGet(t *testing.T) { assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner"}) assert.True(t, tool.Annotations.ReadOnlyHint, "ui_get should be read-only") - assert.Equal(t, MCPAppsFeatureFlag, serverTool.FeatureFlagEnable, "ui_get should be gated on the MCP Apps feature flag") + assert.Empty(t, serverTool.FeatureRule.Features(), "ui_get should not be feature-gated") // ui_get must be app-only so the host hides it from the agent's tool list // while keeping it callable by the views (MCP Apps 2026-01-26 spec). @@ -115,34 +116,34 @@ func Test_UIGet(t *testing.T) { // Setup mock data mockAssignees := []*github.User{ - {Login: github.Ptr("user1"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1")}, - {Login: github.Ptr("user2"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/2")}, + {Login: new("user1"), AvatarURL: new("https://avatars.githubusercontent.com/u/1")}, + {Login: new("user2"), AvatarURL: new("https://avatars.githubusercontent.com/u/2")}, } mockBranches := []*github.Branch{ - {Name: github.Ptr("main"), Protected: github.Ptr(true)}, - {Name: github.Ptr("feature"), Protected: github.Ptr(false)}, + {Name: new("main"), Protected: new(true)}, + {Name: new("feature"), Protected: new(false)}, } dueDate := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC) mockMilestones := []*github.Milestone{ - {Number: github.Ptr(1), Title: github.Ptr("with due date"), DueOn: &github.Timestamp{Time: dueDate}}, - {Number: github.Ptr(2), Title: github.Ptr("no due date")}, + {Number: new(1), Title: new("with due date"), DueOn: &github.Timestamp{Time: dueDate}}, + {Number: new(2), Title: new("no due date")}, } mockIssueTypes := []*github.IssueType{ - {Name: github.Ptr("Bug")}, - {Name: github.Ptr("Feature")}, + {Name: new("Bug")}, + {Name: new("Feature")}, } mockReviewers := []*github.User{ - {Login: github.Ptr("octocat"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/583231")}, - {Login: github.Ptr("dependabot[bot]"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/in/29110")}, - {Login: github.Ptr("github-actions"), Type: github.Ptr("Bot")}, + {Login: new("octocat"), AvatarURL: new("https://avatars.githubusercontent.com/u/583231")}, + {Login: new("dependabot[bot]"), AvatarURL: new("https://avatars.githubusercontent.com/in/29110")}, + {Login: new("github-actions"), Type: new("Bot")}, } mockReviewerTeams := []*github.Team{ - {Slug: github.Ptr("docs"), Name: github.Ptr("Docs")}, + {Slug: new("docs"), Name: new("Docs")}, } tests := []struct { @@ -384,7 +385,7 @@ func Test_UIGet(t *testing.T) { { name: "branches pagination stops at the page cap", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "GET /repos/owner/repo/branches": alwaysNextPageHandler(t, []*github.Branch{{Name: github.Ptr("feature")}}), + "GET /repos/owner/repo/branches": alwaysNextPageHandler(t, []*github.Branch{{Name: new("feature")}}), }), requestArgs: map[string]any{ "method": "branches", @@ -426,7 +427,7 @@ func Test_UIGet(t *testing.T) { { name: "reviewers pagination stops at the page cap", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "GET /repos/owner/repo/collaborators": alwaysNextPageHandler(t, []*github.User{{Login: github.Ptr("octocat")}}), + "GET /repos/owner/repo/collaborators": alwaysNextPageHandler(t, []*github.User{{Login: new("octocat")}}), "GET /repos/owner/repo/teams": mockResponse(t, http.StatusOK, mockReviewerTeams), }), requestArgs: map[string]any{ @@ -582,3 +583,22 @@ func Test_marshalUIGetIssueFields_TrimsForUI(t *testing.T) { textField := fields[1].(map[string]any) assert.NotContains(t, textField, "options") } + +func Test_UIGet_OmittedForClientsWithoutUISupport(t *testing.T) { + inv, err := NewInventory(translations.NullTranslationHelper).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + + hasUIGet := func(ctx context.Context) bool { + for _, tool := range inv.ToolsForRegistration(ctx) { + if tool.Tool.Name == "ui_get" { + return true + } + } + return false + } + + assert.False(t, hasUIGet(ghcontext.WithUISupport(context.Background(), false)), + "app-only ui_get must not be exposed as a model-visible tool to non-UI clients") + assert.True(t, hasUIGet(ghcontext.WithUISupport(context.Background(), true))) + assert.True(t, hasUIGet(context.Background()), "ui_get is kept when UI support is unknown") +} diff --git a/pkg/github/workflow_enums.go b/pkg/github/workflow_enums.go new file mode 100644 index 0000000000..ec68a14747 --- /dev/null +++ b/pkg/github/workflow_enums.go @@ -0,0 +1,50 @@ +package github + +import "slices" + +// WorkflowStatus is a GitHub Actions workflow-run status. +type WorkflowStatus string + +var workflowStatusValues = []string{ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", +} + +// Values returns the supported workflow statuses. +func (WorkflowStatus) Values() []string { + return slices.Clone(workflowStatusValues) +} + +// WorkflowStatusValues returns the supported workflow statuses. +func WorkflowStatusValues() []string { + return (WorkflowStatus("")).Values() +} + +// WorkflowConclusion is a GitHub Actions workflow-run conclusion. +type WorkflowConclusion string + +var workflowConclusionValues = []string{ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure", +} + +// Values returns the supported workflow conclusions. +func (WorkflowConclusion) Values() []string { + return slices.Clone(workflowConclusionValues) +} + +// WorkflowConclusionValues returns the supported workflow conclusions. +func WorkflowConclusionValues() []string { + return (WorkflowConclusion("")).Values() +} diff --git a/pkg/github/workflow_prompts.go b/pkg/github/workflow_prompts.go index e85c933486..415d5a74cf 100644 --- a/pkg/github/workflow_prompts.go +++ b/pkg/github/workflow_prompts.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/http/handler.go b/pkg/http/handler.go index e4a9d198ec..25c02f254c 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -7,15 +7,15 @@ import ( "net/http" "slices" - 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/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" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/middleware" + "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/go-chi/chi/v5" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -23,6 +23,11 @@ import ( const subscriptionsListenMethod = "subscriptions/listen" +// InventoryFactoryFunc builds the inventory for one HTTP request. All context +// values required by its feature checker must be installed before this runs: +// feature availability is resolved immediately afterward, before MCP receiving +// middleware can run. Handler-only lazy checks still see receiving-middleware +// context. type InventoryFactoryFunc func(r *http.Request) (*inventory.Inventory, error) // GitHubMCPServerFactoryFunc is a function type for creating a new MCP Server instance. @@ -214,6 +219,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if methodInfo, ok := ghcontext.MCPMethod(r.Context()); ok && methodInfo != nil { invToUse = inv.ForMCPRequest(methodInfo.Method, methodInfo.ItemName) } + // Tool registration must know availability before the MCP server exists. + // Remote consumers install user identity in HTTP middleware before the + // inventory factory, so their per-user checker has its full context here. + r = r.WithContext(invToUse.WithFeatureState(r.Context())) ghServer, err := h.githubMcpServerFactory(r, h.deps, invToUse, &github.MCPServerConfig{ Version: h.config.Version, diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go index f051084785..953a564da4 100644 --- a/pkg/http/handler_test.go +++ b/pkg/http/handler_test.go @@ -12,15 +12,16 @@ import ( "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/http/middleware" - "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" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/middleware" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/go-chi/chi/v5" + "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" @@ -60,12 +61,54 @@ func (f allScopesFetcher) FetchTokenScopes(_ context.Context, _ string) ([]strin var _ scopes.FetcherInterface = allScopesFetcher{} -func mockToolWithFeatureFlag(name, toolsetID string, readOnly bool, enableFlag, disableFlag string) inventory.ServerTool { +func TestDefaultInventoryFactoryReusesSchemaDefinitions(t *testing.T) { + t.Parallel() + for _, host := range []string{"https://github.com", "https://example.ghe.com", "https://github.example.com"} { + t.Run(host, func(t *testing.T) { + factory, err := NewDefaultInventoryFactory(&ServerConfig{Host: host}, translations.NullTranslationHelper, nil, allScopesFetcher{}) + require.NoError(t, err) + request := httptest.NewRequest(http.MethodPost, "/", nil) + request = request.WithContext(ghcontext.WithToolsets(request.Context(), []string{"all"})) + first, err := factory(request) + require.NoError(t, err) + definitions := first.AllTools() + require.NotEmpty(t, definitions) + for range 10 { + next, err := factory(request) + require.NoError(t, err) + tools := next.AllTools() + require.Len(t, tools, len(definitions)) + for i, tool := range tools { + assert.Equal(t, definitions[i].Tool.Name, tool.Tool.Name) + if _, ok := definitions[i].Tool.InputSchema.(*jsonschema.Schema); ok { + assert.Same(t, definitions[i].Tool.InputSchema, tool.Tool.InputSchema) + } else { + assert.Equal(t, definitions[i].Tool.InputSchema, tool.Tool.InputSchema) + } + if _, ok := definitions[i].Tool.OutputSchema.(*jsonschema.Schema); ok { + assert.Same(t, definitions[i].Tool.OutputSchema, tool.Tool.OutputSchema) + } else { + assert.Equal(t, definitions[i].Tool.OutputSchema, tool.Tool.OutputSchema) + } + } + } + }) + } +} + +func mockToolWithFeatureFlag(name, toolsetID string, readOnly bool, enableFlag, disableFlag inventory.FeatureFlag) inventory.ServerTool { tool := mockTool(name, toolsetID, readOnly) - tool.FeatureFlagEnable = enableFlag + features := make([]inventory.FeatureFlag, 0, 2) + if enableFlag != "" { + features = append(features, enableFlag) + } if disableFlag != "" { - tool.FeatureFlagDisable = []string{disableFlag} + features = append(features, disableFlag) } + tool.FeatureRule = inventory.NewFeatureRule(features, func(featureAsBool inventory.FeatureResolver) bool { + return (enableFlag == "" || featureAsBool(enableFlag)) && + (disableFlag == "" || !featureAsBool(disableFlag)) + }) return tool } @@ -177,9 +220,9 @@ func testTools() []inventory.ServerTool { 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"), + // Feature-flagged tools for testing per-request feature selection. + mockToolWithFeatureFlag("needs_holdback", "repos", true, github.FeatureFlagIssueDependencies, ""), + mockToolWithFeatureFlag("hidden_by_holdback", "repos", true, "", github.FeatureFlagIssueDependencies), } } @@ -293,7 +336,7 @@ func TestHTTPHandlerRoutes(t *testing.T) { name: "X-MCP-Features header enables flagged tool", path: "/", headers: map[string]string{ - headers.MCPFeaturesHeader: "mcp_holdback_consolidated_projects", + headers.MCPFeaturesHeader: github.FeatureFlagIssueDependencies, }, expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "needs_holdback"}, }, @@ -305,6 +348,29 @@ func TestHTTPHandlerRoutes(t *testing.T) { }, expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"}, }, + { + name: "features query parameter enables allowlisted feature", + path: "/?features=" + github.FeatureFlagIssueDependencies, + expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "needs_holdback"}, + }, + { + name: "features query parameter works with toolset and readonly routes", + path: "/x/repos/readonly?features=" + github.FeatureFlagIssueDependencies, + expectedTools: []string{"get_file_contents", "needs_holdback"}, + }, + { + name: "unknown feature in query parameter is ignored", + path: "/?features=unknown_flag", + expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"}, + }, + { + name: "unknown header suppresses allowlisted query feature", + path: "/?features=" + github.FeatureFlagIssueDependencies, + 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: "/", @@ -346,10 +412,13 @@ func TestHTTPHandlerRoutes(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) + // Match the production allowlist and insiders expansion behavior. featureChecker := func(ctx context.Context, flag string) (bool, error) { - return slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag), nil + effective := github.ResolveFeatureFlags( + ghcontext.GetHeaderFeatures(ctx), + ghcontext.IsInsidersMode(ctx), + ) + return effective[flag], nil } apiHost, err := utils.NewAPIHost("https://api.github.com") @@ -737,7 +806,9 @@ func TestStaticInventoryPreservesPerRequestFeatureVariants(t *testing.T) { 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) + assert.True(t, available[0].FeatureRule.Enabled(func(flag inventory.FeatureFlag) bool { + return flag == github.FeatureFlagCSVOutput + })) } func TestStaticInventoryDisablesOnlyDeleteRepository(t *testing.T) { @@ -1011,6 +1082,74 @@ func TestCrossOriginProtection(t *testing.T) { } } +func TestFeatureResolutionUsesOuterHTTPContext(t *testing.T) { + type userContextKey struct{} + const ( + userValue = "remote-user" + featureFlag = inventory.FeatureFlag("remote-feature") + ) + + var checkerCalls int + tool := mockTool("feature_tool", "test", true) + tool.FeatureRule = inventory.NewFeatureRule( + []inventory.FeatureFlag{featureFlag}, + func(featureAsBool inventory.FeatureResolver) bool { + return featureAsBool(featureFlag) + }, + ) + inventoryFactory := func(_ *http.Request) (*inventory.Inventory, error) { + checker := func(ctx context.Context, flag string) (bool, error) { + checkerCalls++ + return flag == string(featureFlag) && ctx.Value(userContextKey{}) == userValue, nil + } + return inventory.NewBuilder(). + SetTools([]inventory.ServerTool{tool}). + WithToolsets([]string{"all"}). + WithFeatureChecker(checker). + 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(func(r *http.Request, _ github.ToolDependencies, inv *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) { + assert.True(t, inventory.ResolveFeature(r.Context(), nil, featureFlag)) + require.Len(t, inv.AvailableTools(r.Context()), 1) + return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil + }), + WithScopeFetcher(allScopesFetcher{}), + ) + + router := chi.NewRouter() + router.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey{}, userValue))) + }) + }) + handler.RegisterMiddleware(router) + handler.RegisterRoutes(router) + + body := `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}` + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.Header.Set(headers.ContentTypeHeader, headers.ContentTypeJSON) + req.Header.Set(headers.AcceptHeader, strings.Join([]string{headers.ContentTypeJSON, headers.ContentTypeEventStream}, ", ")) + req.Header.Set("Mcp-Protocol-Version", "2026-07-28") + req.Header.Set("Mcp-Method", "tools/list") + req.Header.Set(headers.AuthorizationHeader, "ghs_test-token") + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + require.Equal(t, http.StatusOK, recorder.Code, "response body: %s", recorder.Body.String()) + assert.Equal(t, 1, checkerCalls) +} + func TestHTTPToolMinimumProtocolVersion(t *testing.T) { apiHost, err := utils.NewAPIHost("https://api.github.com") require.NoError(t, err) @@ -1122,6 +1261,145 @@ func TestHTTPToolMinimumProtocolVersion(t *testing.T) { } } +func TestHTTPStatelessTypedOutputProtocolHeaders(t *testing.T) { + type output struct { + Values []string `json:"values"` + } + tool := inventory.NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_http_tool"}, + inventory.ToolsetMetadata{ID: inventory.ToolsetID("test"), Description: "Test tool"}, + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, output, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "legacy text"}}, + }, output{Values: []string{"one", "two"}}, nil + }, + ) + inv, err := inventory.NewBuilder(). + SetTools([]inventory.ServerTool{tool}). + WithToolsets([]string{"all"}). + Build() + require.NoError(t, err) + + 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(func(*http.Request) (*inventory.Inventory, error) { + return inv, nil + }), + WithGitHubMCPServerFactory(func(_ *http.Request, _ github.ToolDependencies, inv *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) { + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil) + inv.RegisterTools(context.Background(), server, nil) + return server, nil + }), + WithScopeFetcher(allScopesFetcher{}), + ) + + for _, tc := range []struct { + name string + headerVersion string + metaVersion string + wantModern bool + }{ + {name: "modern header and metadata", headerVersion: inventory.ProtocolVersionMultiRoundTrip, metaVersion: inventory.ProtocolVersionMultiRoundTrip, wantModern: true}, + {name: "legacy header and metadata", headerVersion: "2025-11-25", metaVersion: "2025-11-25"}, + {name: "absent protocol version"}, + } { + t.Run(tc.name, func(t *testing.T) { + post := func(method string, id int) map[string]json.RawMessage { + t.Helper() + params := map[string]any{"name": "typed_http_tool", "arguments": map[string]any{}} + if tc.metaVersion != "" { + params["_meta"] = map[string]any{ + mcp.MetaKeyProtocolVersion: tc.metaVersion, + mcp.MetaKeyClientInfo: map[string]any{"name": "test", "version": "1.0.0"}, + mcp.MetaKeyClientCapabilities: map[string]any{}, + } + } + requestBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(requestBody))) + req.Header.Set(headers.ContentTypeHeader, headers.ContentTypeJSON) + req.Header.Set(headers.AcceptHeader, strings.Join([]string{headers.ContentTypeJSON, headers.ContentTypeEventStream}, ", ")) + req.Header.Set(headers.MCPMethodHeader, method) + if method == "tools/call" { + req.Header.Set(headers.MCPNameHeader, "typed_http_tool") + } + if tc.headerVersion != "" { + req.Header.Set(headers.MCPProtocolVersionHeader, tc.headerVersion) + } + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + require.Equal(t, http.StatusOK, recorder.Code, "response body: %s", recorder.Body.String()) + + responseBody := recorder.Body.String() + for line := range strings.SplitSeq(responseBody, "\n") { + if data, ok := strings.CutPrefix(line, "data: "); ok { + responseBody = data + break + } + } + var response struct { + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + } + require.NoError(t, json.Unmarshal([]byte(responseBody), &response), "response body: %s", responseBody) + require.Empty(t, response.Error, "JSON-RPC error: %s", response.Error) + + var result map[string]json.RawMessage + require.NoError(t, json.Unmarshal(response.Result, &result)) + return result + } + + listResult := post("tools/list", 1) + var listed []struct { + Name string `json:"name"` + OutputSchema json.RawMessage `json:"outputSchema"` + } + require.NoError(t, json.Unmarshal(listResult["tools"], &listed)) + require.Len(t, listed, 1) + require.Equal(t, "typed_http_tool", listed[0].Name) + if tc.wantModern { + assert.JSONEq(t, `{"type":"object","properties":{"values":{"type":["null","array"],"items":{"type":"string"}}},"required":["values"],"additionalProperties":false}`, string(listed[0].OutputSchema)) + } else { + assert.Empty(t, listed[0].OutputSchema, "legacy and unknown protocol versions must not advertise output schemas") + } + + callResult := post("tools/call", 2) + var called struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + StructuredContent json.RawMessage `json:"structuredContent"` + } + callResultJSON, err := json.Marshal(callResult) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(callResultJSON, &called)) + require.Len(t, called.Content, 1, "SDK fallback serialization must not duplicate the existing text") + assert.Equal(t, "text", called.Content[0].Type) + if tc.wantModern { + assert.JSONEq(t, `{"values":["one","two"]}`, string(called.StructuredContent)) + assert.Equal(t, `{"values":["one","two"]}`, called.Content[0].Text) + } else { + assert.Equal(t, "legacy text", called.Content[0].Text) + assert.Empty(t, called.StructuredContent, "legacy and unknown protocol versions must not expose structured content") + } + }) + } +} + func TestSubscriptionsListenIsRejected(t *testing.T) { apiHost, err := utils.NewAPIHost("https://api.githubcopilot.com") require.NoError(t, err) @@ -1196,62 +1474,36 @@ func TestSubscriptionsListenIsRejected(t *testing.T) { } } -// 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) { +// TestUIMetaPreservedByDefault verifies that _meta.ui is preserved in the +// registered tool surface on both the /insiders route and the default route: +// MCP Apps UI metadata is no longer gated behind a feature flag. +func TestUIMetaPreservedByDefault(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) + inv, err := inventory.NewBuilder(). + SetTools([]inventory.ServerTool{uiTool}). + WithFeatureChecker(createHTTPFeatureChecker(nil, false)). + WithToolsets([]string{"all"}). + Build() + require.NoError(t, err) - // 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) + for name, ctx := range map[string]context.Context{ + "insiders": ghcontext.WithInsidersMode(context.Background(), true), + "default": context.Background(), + } { + t.Run(name, func(t *testing.T) { + tools := inv.ToolsForRegistration(ctx) + require.Len(t, tools, 1) + require.NotNil(t, tools[0].Tool.Meta, "_meta should be present") + require.Equal(t, uiURI, tools[0].Tool.Meta["ui"].(map[string]any)["resourceUri"]) + }) + } } -// 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 +// TestUIMetaStrippedWhenClientLacksCapability verifies that 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. @@ -1284,10 +1536,9 @@ func TestUIMetaStrippedWhenClientLacksCapability(t *testing.T) { 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") + require.NotNil(t, unknown[0].Tool.Meta["ui"], "_meta.ui should be preserved when capability is unknown") } // TestMaxRequestBodyBytes checks the effective limit and, critically, that it diff --git a/pkg/http/headers/headers.go b/pkg/http/headers/headers.go index 280875ef5b..732dcb889c 100644 --- a/pkg/http/headers/headers.go +++ b/pkg/http/headers/headers.go @@ -39,8 +39,18 @@ const ( // MCP-specific headers. + // MCPProtocolVersionHeader carries the protocol version for HTTP MCP requests. + MCPProtocolVersionHeader = "MCP-Protocol-Version" // MCPMethodHeader mirrors the JSON-RPC method for request routing. MCPMethodHeader = "Mcp-Method" + // MCPNameHeader identifies the requested MCP primitive. + MCPNameHeader = "Mcp-Name" + // MCPParamHeaderPrefix prefixes request headers projected from MCP parameters. + MCPParamHeaderPrefix = "Mcp-Param-" + // MCPParamOwnerHeader carries the projected owner parameter. + MCPParamOwnerHeader = MCPParamHeaderPrefix + "owner" + // MCPParamRepoHeader carries the projected repo parameter. + MCPParamRepoHeader = MCPParamHeaderPrefix + "repo" // 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. diff --git a/pkg/http/middleware/cors.go b/pkg/http/middleware/cors.go index 409d134127..1e23596962 100644 --- a/pkg/http/middleware/cors.go +++ b/pkg/http/middleware/cors.go @@ -2,42 +2,96 @@ package middleware import ( "net/http" + "sort" "strings" - "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "golang.org/x/net/http/httpguts" ) +const maxCORSProjectedRequestHeaders = 64 + +var corsAllowedRequestHeaders = []string{ + headers.ContentTypeHeader, + "Mcp-Session-Id", + "Mcp-Protocol-Version", + headers.MCPMethodHeader, + headers.MCPNameHeader, + "Last-Event-ID", + headers.AuthorizationHeader, + headers.MCPReadOnlyHeader, + headers.MCPToolsetsHeader, + headers.MCPToolsHeader, + headers.MCPExcludeToolsHeader, + headers.MCPFeaturesHeader, + headers.MCPLockdownHeader, + headers.MCPInsidersHeader, + headers.MCPParamOwnerHeader, + headers.MCPParamRepoHeader, +} + // 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, - }, ", ") + fixedAllowHeaders := strings.Join(corsAllowedRequestHeaders, ", ") 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().Add("Access-Control-Expose-Headers", "Mcp-Session-Id, WWW-Authenticate") - w.Header().Set("Access-Control-Allow-Headers", allowHeaders) + w.Header().Set("Access-Control-Allow-Headers", fixedAllowHeaders) if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Headers", corsPreflightAllowedRequestHeaders(r.Header)) w.WriteHeader(http.StatusOK) return } h.ServeHTTP(w, r) }) } + +// corsPreflightAllowedRequestHeaders reflects validated projected arguments because CORS has no prefix wildcard. +func corsPreflightAllowedRequestHeaders(requestHeaders http.Header) string { + allowed := make([]string, 0, len(corsAllowedRequestHeaders)) + allowed = append(allowed, corsAllowedRequestHeaders...) + + seen := make(map[string]struct{}, len(allowed)) + for _, header := range allowed { + seen[strings.ToLower(header)] = struct{}{} + } + + prefix := strings.ToLower(headers.MCPParamHeaderPrefix) + projected := make([]string, 0, maxCORSProjectedRequestHeaders) +requestedHeaders: + for _, value := range requestHeaders.Values("Access-Control-Request-Headers") { + for header := range strings.SplitSeq(value, ",") { + header = strings.TrimSpace(header) + if !httpguts.ValidHeaderFieldName(header) { + continue + } + + key := strings.ToLower(header) + if !strings.HasPrefix(key, prefix) || len(key) == len(prefix) { + continue + } + if _, ok := seen[key]; ok { + continue + } + + seen[key] = struct{}{} + projected = append(projected, key) + if len(projected) == maxCORSProjectedRequestHeaders { + break requestedHeaders + } + } + } + + sort.Strings(projected) + for _, header := range projected { + allowed = append(allowed, http.CanonicalHeaderKey(header)) + } + return strings.Join(allowed, ", ") +} diff --git a/pkg/http/middleware/cors_test.go b/pkg/http/middleware/cors_test.go index 67305e0ac8..4572edf284 100644 --- a/pkg/http/middleware/cors_test.go +++ b/pkg/http/middleware/cors_test.go @@ -1,61 +1,141 @@ package middleware_test import ( + "fmt" "net/http" "net/http/httptest" "strings" "testing" - "github.com/github/github-mcp-server/pkg/http/middleware" + "github.com/github/github-mcp-server/v2/pkg/http/middleware" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestSetCorsHeaders(t *testing.T) { +const fixedAllowedRequestHeaders = "Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Mcp-Method, Mcp-Name, Last-Event-ID, Authorization, X-MCP-Readonly, X-MCP-Toolsets, X-MCP-Tools, X-MCP-Exclude-Tools, X-MCP-Features, X-MCP-Lockdown, X-MCP-Insiders, Mcp-Param-owner, Mcp-Param-repo" + +func TestSetCorsHeadersPreflight(t *testing.T) { + tests := []struct { + name string + requestedHeadersValues []string + expectedAllowedHeaders string + }{ + { + name: "current MCP request headers", + requestedHeadersValues: []string{ + "authorization, content-type, mcp-protocol-version, mcp-method, mcp-name, mcp-param-owner, mcp-param-repo", + }, + expectedAllowedHeaders: fixedAllowedRequestHeaders, + }, + { + name: "future projected parameter", + requestedHeadersValues: []string{"Mcp-Param-region"}, + expectedAllowedHeaders: fixedAllowedRequestHeaders + ", Mcp-Param-Region", + }, + { + name: "mixed case duplicates across values", + requestedHeadersValues: []string{ + "mCp-PaRaM-ReGiOn, MCP-PARAM-ZONE", + "MCP-PARAM-REGION, mcp-param-zone", + }, + expectedAllowedHeaders: fixedAllowedRequestHeaders + ", Mcp-Param-Region, Mcp-Param-Zone", + }, + { + name: "invalid unrelated bare and lookalike names", + requestedHeadersValues: []string{ + "Mcp-Param-, X-Evil, XMcp-Param-region, Mcp_Param-region, Mcp-Param-\x00region, Mcp-Param-bad name", + }, + expectedAllowedHeaders: fixedAllowedRequestHeaders, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + innerCalled := false + handler := middleware.SetCorsHeaders(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + innerCalled = true + })) + req := httptest.NewRequest(http.MethodOptions, "/", nil) + req.Header.Set("Origin", "https://confer.to") + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + for _, value := range tt.requestedHeadersValues { + req.Header.Add("Access-Control-Request-Headers", value) + } + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.False(t, innerCalled) + assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin")) + assert.Empty(t, rr.Header().Get("Access-Control-Allow-Credentials")) + assert.Equal(t, "GET, POST, DELETE, OPTIONS", rr.Header().Get("Access-Control-Allow-Methods")) + assert.Equal(t, "86400", rr.Header().Get("Access-Control-Max-Age")) + assert.Equal(t, tt.expectedAllowedHeaders, rr.Header().Get("Access-Control-Allow-Headers")) + assert.Equal(t, "Mcp-Session-Id, WWW-Authenticate", rr.Header().Get("Access-Control-Expose-Headers")) + assert.NotContains(t, rr.Header().Get("Access-Control-Expose-Headers"), "Mcp-Param-") + }) + } +} + +func TestSetCorsHeadersPreflightBoundsProjectedHeaders(t *testing.T) { + const ( + maxProjectedHeaders = 64 + requestedHeaders = 1024 + ) + + requested := make([]string, 0, requestedHeaders*2) + expected := strings.Split(fixedAllowedRequestHeaders, ", ") + for i := range requestedHeaders { + header := fmt.Sprintf("mcp-param-%04d", i) + requested = append(requested, header, strings.ToUpper(header)) + if i < maxProjectedHeaders { + expected = append(expected, http.CanonicalHeaderKey(header)) + } + } + requestedValue := strings.Join(requested, ", ") + + handler := middleware.SetCorsHeaders(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("preflight reached the inner handler") + })) + req := httptest.NewRequest(http.MethodOptions, "/", nil) + req.Header.Set("Access-Control-Request-Headers", requestedValue) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + allowedValue := rr.Header().Get("Access-Control-Allow-Headers") + assert.Equal(t, expected, strings.Split(allowedValue, ", ")) + assert.NotContains(t, allowedValue, "Mcp-Param-0064") + assert.Less(t, len(allowedValue), len(requestedValue)) +} + +func TestSetCorsHeadersPostPreservesBehavior(t *testing.T) { innerCalled := false inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { innerCalled = true w.Header().Add("Access-Control-Expose-Headers", "X-Existing-Response") - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusCreated) + _, err := w.Write([]byte("created")) + require.NoError(t, err) }) handler := middleware.SetCorsHeaders(inner) + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("Origin", "https://confer.to") + req.Header.Set("Access-Control-Request-Headers", "Mcp-Param-region") + rr := httptest.NewRecorder() - t.Run("OPTIONS preflight returns 200 with CORS headers", func(t *testing.T) { - innerCalled = false - req := httptest.NewRequest(http.MethodOptions, "/", nil) - req.Header.Set("Origin", "https://confer.to") - req.Header.Set("Access-Control-Request-Method", http.MethodPost) - req.Header.Set("Access-Control-Request-Headers", "content-type") - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusOK, rr.Code) - assert.False(t, innerCalled) - assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin")) - assert.Empty(t, rr.Header().Get("Access-Control-Allow-Credentials")) - 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") - }) + handler.ServeHTTP(rr, req) - t.Run("POST request includes CORS headers without replacing existing exposed headers", func(t *testing.T) { - innerCalled = false - req := httptest.NewRequest(http.MethodPost, "/", nil) - req.Header.Set("Origin", "https://confer.to") - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusOK, rr.Code) - assert.True(t, innerCalled) - assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin")) - assert.Empty(t, rr.Header().Get("Access-Control-Allow-Credentials")) - exposedHeaders := strings.Join(rr.Header().Values("Access-Control-Expose-Headers"), ", ") - assert.Contains(t, exposedHeaders, "Mcp-Session-Id") - assert.Contains(t, exposedHeaders, "WWW-Authenticate") - assert.Contains(t, exposedHeaders, "X-Existing-Response") - }) + assert.Equal(t, http.StatusCreated, rr.Code) + assert.True(t, innerCalled) + assert.Equal(t, "created", rr.Body.String()) + assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin")) + assert.Empty(t, rr.Header().Get("Access-Control-Allow-Credentials")) + assert.Equal(t, fixedAllowedRequestHeaders, rr.Header().Get("Access-Control-Allow-Headers")) + exposedHeaders := strings.Join(rr.Header().Values("Access-Control-Expose-Headers"), ", ") + assert.Contains(t, exposedHeaders, "Mcp-Session-Id") + assert.Contains(t, exposedHeaders, "WWW-Authenticate") + assert.Contains(t, exposedHeaders, "X-Existing-Response") + assert.NotContains(t, exposedHeaders, "Mcp-Param-") } diff --git a/pkg/http/middleware/mcp_parse.go b/pkg/http/middleware/mcp_parse.go index 0b56902261..cf5bc7bea4 100644 --- a/pkg/http/middleware/mcp_parse.go +++ b/pkg/http/middleware/mcp_parse.go @@ -6,7 +6,9 @@ import ( "io" "net/http" - ghcontext "github.com/github/github-mcp-server/pkg/context" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/modelcontextprotocol/go-sdk/mcp" ) // mcpJSONRPCRequest represents the structure of an MCP JSON-RPC request. @@ -21,7 +23,11 @@ type mcpJSONRPCRequest struct { // For prompts/get // Name is shared with tools/call // For resources/read - URI string `json:"uri,omitempty"` + URI string `json:"uri,omitempty"` + Meta struct { + ProtocolVersion string `json:"io.modelcontextprotocol/protocolVersion,omitempty"` + ClientCapabilities *mcp.ClientCapabilities `json:"io.modelcontextprotocol/clientCapabilities,omitempty"` + } `json:"_meta"` } `json:"params"` } @@ -82,6 +88,9 @@ func WithMCPParse() func(http.Handler) http.Handler { next.ServeHTTP(w, r) return } + if methodInfo.ProtocolVersion == "" { + methodInfo.ProtocolVersion = r.Header.Get(headers.MCPProtocolVersionHeader) + } // Store the parsed info in context ctx = ghcontext.WithMCPMethodInfo(ctx, methodInfo) @@ -101,7 +110,11 @@ func parseMCPMethodInfo(body []byte) (*ghcontext.MCPMethodInfo, error) { return nil, nil } - methodInfo := &ghcontext.MCPMethodInfo{Method: mcpReq.Method} + methodInfo := &ghcontext.MCPMethodInfo{ + Method: mcpReq.Method, + ProtocolVersion: mcpReq.Params.Meta.ProtocolVersion, + ClientCapabilities: mcpReq.Params.Meta.ClientCapabilities, + } switch mcpReq.Method { case "tools/call": methodInfo.ItemName = mcpReq.Params.Name diff --git a/pkg/http/middleware/mcp_parse_test.go b/pkg/http/middleware/mcp_parse_test.go index e067f7808a..6c6863b154 100644 --- a/pkg/http/middleware/mcp_parse_test.go +++ b/pkg/http/middleware/mcp_parse_test.go @@ -8,23 +8,27 @@ import ( "strings" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/modelcontextprotocol/go-sdk/mcp" "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 - expectedRaw string - expectedArgs map[string]any - expectArgsError bool + name string + method string + path string + body string + headerVersion string + expectInfo bool + expectedMethod string + expectedItem string + expectedRaw string + expectedArgs map[string]any + expectedProtocol string + expectedForm bool + expectArgsError bool }{ { name: "health check path is skipped", @@ -76,6 +80,29 @@ func TestWithMCPParse(t *testing.T) { expectInfo: true, expectedMethod: "tools/list", }, + { + name: "tools/list parses client availability", + method: http.MethodPost, + path: "/mcp", + body: `{"jsonrpc":"2.0","method":"tools/list","params":{"_meta":{ + "io.modelcontextprotocol/protocolVersion":"2026-07-28", + "io.modelcontextprotocol/clientCapabilities":{"elicitation":{"form":{}}} + }}}`, + expectInfo: true, + expectedMethod: "tools/list", + expectedProtocol: "2026-07-28", + expectedForm: true, + }, + { + name: "tools/list falls back to protocol version header", + method: http.MethodPost, + path: "/mcp", + body: `{"jsonrpc":"2.0","method":"tools/list"}`, + headerVersion: "2026-07-28", + expectInfo: true, + expectedMethod: "tools/list", + expectedProtocol: "2026-07-28", + }, { name: "tools/call parses name", method: http.MethodPost, @@ -149,6 +176,9 @@ func TestWithMCPParse(t *testing.T) { handler := middleware(nextHandler) req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body)) + if tt.headerVersion != "" { + req.Header.Set("MCP-Protocol-Version", tt.headerVersion) + } rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) @@ -158,6 +188,12 @@ func TestWithMCPParse(t *testing.T) { require.NotNil(t, capturedInfo) assert.Equal(t, tt.expectedMethod, capturedInfo.Method) assert.Equal(t, tt.expectedItem, capturedInfo.ItemName) + assert.Equal(t, tt.expectedProtocol, capturedInfo.ProtocolVersion) + if tt.expectedForm { + require.NotNil(t, capturedInfo.ClientCapabilities) + require.NotNil(t, capturedInfo.ClientCapabilities.Elicitation) + assert.Equal(t, &mcp.FormElicitationCapabilities{}, capturedInfo.ClientCapabilities.Elicitation.Form) + } if tt.expectedRaw != "" { assert.JSONEq(t, tt.expectedRaw, string(capturedInfo.RawArguments)) } diff --git a/pkg/http/middleware/pat_scope.go b/pkg/http/middleware/pat_scope.go index bb1efdc011..ace4b19c09 100644 --- a/pkg/http/middleware/pat_scope.go +++ b/pkg/http/middleware/pat_scope.go @@ -4,9 +4,9 @@ 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" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/utils" ) // WithPATScopes is a middleware that fetches and stores scopes for classic Personal Access Tokens (PATs) in the request context. diff --git a/pkg/http/middleware/pat_scope_test.go b/pkg/http/middleware/pat_scope_test.go index 0607b8cf2b..bb5ff669fa 100644 --- a/pkg/http/middleware/pat_scope_test.go +++ b/pkg/http/middleware/pat_scope_test.go @@ -8,8 +8,8 @@ import ( "net/http/httptest" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -55,7 +55,7 @@ func TestWithPATScopes(t *testing.T) { }, { name: "fine-grained PAT skips scope fetching", - tokenInfo: &ghcontext.TokenInfo{ + tokenInfo: &ghcontext.TokenInfo{ //nolint:gosec // G101: Synthetic fine-grained PAT fixture verifies scope fetching is skipped; it cannot authenticate. Token: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx", TokenType: utils.TokenTypeFineGrainedPersonalAccessToken, }, diff --git a/pkg/http/middleware/request_config.go b/pkg/http/middleware/request_config.go index a7311334d3..6102825192 100644 --- a/pkg/http/middleware/request_config.go +++ b/pkg/http/middleware/request_config.go @@ -5,14 +5,19 @@ import ( "slices" "strings" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/headers" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/headers" ) +const queryParamFeatures = "features" + // 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) { + // Header-selected features can change the response for the same URL. + w.Header().Add(headers.VaryHeader, headers.MCPFeaturesHeader) + ctx := r.Context() // Readonly mode @@ -45,9 +50,13 @@ func WithRequestConfig(next http.Handler) http.Handler { ctx = ghcontext.WithInsidersMode(ctx, true) } - // Feature flags - if features := headers.ParseCommaSeparated(r.Header.Get(headers.MCPFeaturesHeader)); len(features) > 0 { - ctx = ghcontext.WithHeaderFeatures(ctx, features) + query := r.URL.Query() + _, hasHeaderFeatures := r.Header[http.CanonicalHeaderKey(headers.MCPFeaturesHeader)] + _, hasQueryFeatures := query[queryParamFeatures] + if hasHeaderFeatures { + ctx = ghcontext.WithHeaderFeatures(ctx, headers.ParseCommaSeparated(r.Header.Get(headers.MCPFeaturesHeader))) + } else if hasQueryFeatures { + ctx = ghcontext.WithHeaderFeatures(ctx, headers.ParseCommaSeparated(query.Get(queryParamFeatures))) } next.ServeHTTP(w, r.WithContext(ctx)) diff --git a/pkg/http/middleware/request_config_test.go b/pkg/http/middleware/request_config_test.go new file mode 100644 index 0000000000..1937d0986b --- /dev/null +++ b/pkg/http/middleware/request_config_test.go @@ -0,0 +1,112 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/stretchr/testify/assert" +) + +func TestWithRequestConfigFeatureSelection(t *testing.T) { + tests := []struct { + name string + url string + headerSet bool + headerValue string + wantFeatures []string + wantPresent bool + }{ + { + name: "query parameter only", + url: "/?features=mcp_holdback_consolidated_projects", + wantFeatures: []string{"mcp_holdback_consolidated_projects"}, + wantPresent: true, + }, + { + name: "header only", + url: "/", + headerSet: true, + headerValue: "mcp_holdback_consolidated_projects", + wantFeatures: []string{"mcp_holdback_consolidated_projects"}, + wantPresent: true, + }, + { + name: "header wins over query parameter, never combined", + url: "/?features=flag_from_query", + headerSet: true, + headerValue: "flag_from_header", + wantFeatures: []string{"flag_from_header"}, + wantPresent: true, + }, + { + name: "empty header suppresses query parameter", + url: "/?features=flag_from_query", + headerSet: true, + wantFeatures: []string{}, + wantPresent: true, + }, + { + name: "whitespace-only header suppresses query parameter", + url: "/?features=flag_from_query", + headerSet: true, + headerValue: " , \t ", + wantFeatures: []string{}, + wantPresent: true, + }, + { + name: "unknown header suppresses query parameter", + url: "/?features=flag_from_query", + headerSet: true, + headerValue: "unknown_from_header", + wantFeatures: []string{"unknown_from_header"}, + wantPresent: true, + }, + { + name: "empty query value with header", + url: "/?features=", + headerSet: true, + headerValue: "flag_from_header", + wantFeatures: []string{"flag_from_header"}, + wantPresent: true, + }, + { + name: "empty query value stores an explicit empty selection", + url: "/?features=", + wantFeatures: []string{}, + wantPresent: true, + }, + { + name: "no channel present stores nothing", + url: "/", + wantFeatures: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var got []string + handler := WithRequestConfig(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = ghcontext.GetHeaderFeatures(r.Context()) + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodPost, tc.url, nil) + if tc.headerSet { + req.Header.Set(headers.MCPFeaturesHeader, tc.headerValue) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, tc.wantFeatures, got) + if tc.wantPresent { + assert.NotNil(t, got) + } else { + assert.Nil(t, got) + } + assert.Contains(t, rec.Header().Values(headers.VaryHeader), headers.MCPFeaturesHeader) + }) + } +} diff --git a/pkg/http/middleware/scope_challenge.go b/pkg/http/middleware/scope_challenge.go index 7e4bee6dc5..77db625be5 100644 --- a/pkg/http/middleware/scope_challenge.go +++ b/pkg/http/middleware/scope_challenge.go @@ -2,15 +2,16 @@ 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" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/utils" ) // WithScopeChallenge creates a new middleware that determines if an OAuth request contains sufficient scopes to @@ -102,8 +103,27 @@ func WithScopeChallenge(oauthCfg *oauth.Config, scopeFetcher scopes.FetcherInter return } - arguments, err := methodInfo.DecodeArguments() - if err != nil { + rawArguments := methodInfo.RawArguments + if len(rawArguments) == 0 { + rawArguments = []byte(`{}`) + } + argumentsJSON, didNormalize, normalizeErr := scopeAccess.NormalizeArguments(rawArguments) + if normalizeErr != nil { + // Preserve normal tool validation for arguments the normalizer rejects. + next.ServeHTTP(w, r) + return + } + if !didNormalize { + argumentsJSON = rawArguments + } else { + normalizedInfo := *methodInfo + normalizedInfo.NormalizedArguments = argumentsJSON + normalizedInfo.ArgumentsNormalized = true + ctx = ghcontext.WithMCPMethodInfo(ctx, &normalizedInfo) + r = r.WithContext(ctx) + } + arguments := make(map[string]any) + if err := json.Unmarshal(argumentsJSON, &arguments); err != nil { // Preserve normal MCP handler validation for invalid arguments. next.ServeHTTP(w, r) return diff --git a/pkg/http/middleware/scope_challenge_test.go b/pkg/http/middleware/scope_challenge_test.go index 1c83ba600c..232624b350 100644 --- a/pkg/http/middleware/scope_challenge_test.go +++ b/pkg/http/middleware/scope_challenge_test.go @@ -4,17 +4,18 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" - 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/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -153,6 +154,68 @@ func TestWithScopeChallengeResolvesScopesFromParsedArguments(t *testing.T) { } } +func TestWithScopeChallengeNormalizesArgumentsBeforeDynamicPolicy(t *testing.T) { + normalizerCalls := 0 + var challengedArguments map[string]any + setScopeTestMap(t, inventory.ScopeAccess{ + Scopes: []string{"repo", "workflow"}, + Dynamic: true, + ArgumentNormalizer: func(raw json.RawMessage) (json.RawMessage, error) { + normalizerCalls++ + var arguments map[string]json.RawMessage + if err := json.Unmarshal(raw, &arguments); err != nil { + return nil, err + } + arguments["state"] = json.RawMessage(`"OPEN"`) + return json.Marshal(arguments) + }, + Challenge: func(arguments map[string]any, _ []string) []string { + challengedArguments = arguments + if arguments["state"] == "OPEN" { + return nil + } + return []string{"workflow"} + }, + }) + + for _, parsed := range []bool{true, false} { + t.Run(fmt.Sprintf("parsed=%t", parsed), func(t *testing.T) { + normalizerCalls = 0 + nextCalled := false + var normalizedInfo *ghcontext.MCPMethodInfo + next := http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { + nextCalled = true + normalizedInfo, _ = ghcontext.MCPMethod(request.Context()) + }) + request := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader( + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"write_file","arguments":{"state":"open"}}}`, + )) + ctx := scopeChallengeContext(request.Context()) + methodInfo := &ghcontext.MCPMethodInfo{ + Method: "tools/call", + ItemName: "write_file", + RawArguments: json.RawMessage(`{"state":"open"}`), + } + if parsed { + ctx = ghcontext.WithMCPMethodInfo(ctx, methodInfo) + } + request = request.WithContext(ctx) + + response := httptest.NewRecorder() + WithScopeChallenge(&oauth.Config{}, &mockScopeFetcher{})(next).ServeHTTP(response, request) + + require.Equal(t, http.StatusOK, response.Code) + require.True(t, nextCalled) + assert.Equal(t, 1, normalizerCalls) + assert.Equal(t, "OPEN", challengedArguments["state"]) + require.NotNil(t, normalizedInfo) + assert.True(t, normalizedInfo.ArgumentsNormalized) + assert.JSONEq(t, `{"state":"OPEN"}`, string(normalizedInfo.NormalizedArguments)) + assert.False(t, methodInfo.ArgumentsNormalized, "normalization must not mutate the original parsed metadata") + }) + } +} + func TestWithScopeChallengeResolvesScopesFromFallbackBody(t *testing.T) { setDynamicScopeTestMap(t) @@ -359,7 +422,7 @@ func scopeChallengeContext(ctx context.Context) context.Context { } func scopeChallengeContextWithScopes(ctx context.Context, activeScopes []string) context.Context { - ctx = ghcontext.WithTokenInfo(ctx, &ghcontext.TokenInfo{ + ctx = ghcontext.WithTokenInfo(ctx, &ghcontext.TokenInfo{ //nolint:gosec // G101: "oauth-token" is a synthetic context fixture, not an authentication credential. Token: "oauth-token", TokenType: utils.TokenTypeOAuthAccessToken, }) diff --git a/pkg/http/middleware/token.go b/pkg/http/middleware/token.go index 012bbabef2..951a86e34a 100644 --- a/pkg/http/middleware/token.go +++ b/pkg/http/middleware/token.go @@ -5,9 +5,9 @@ import ( "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" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/utils" ) func ExtractUserToken(oauthCfg *oauth.Config) func(next http.Handler) http.Handler { diff --git a/pkg/http/middleware/token_test.go b/pkg/http/middleware/token_test.go index fa8f0ee98e..664d957528 100644 --- a/pkg/http/middleware/token_test.go +++ b/pkg/http/middleware/token_test.go @@ -5,10 +5,10 @@ import ( "net/http/httptest" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/headers" - "github.com/github/github-mcp-server/pkg/http/oauth" - "github.com/github/github-mcp-server/pkg/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -37,7 +37,7 @@ func TestExtractUserToken(t *testing.T) { expectWWWAuth: true, }, // Personal Access Token (classic) - ghp_ prefix - { + { //nolint:gosec // G101: Synthetic token fixture exercises classic PAT prefix detection; it cannot authenticate. name: "personal access token (classic) with Bearer prefix", authHeader: "Bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", expectedStatusCode: http.StatusOK, @@ -45,7 +45,7 @@ func TestExtractUserToken(t *testing.T) { expectedToken: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", expectTokenInfo: true, }, - { + { //nolint:gosec // G101: Synthetic token fixture exercises lowercase bearer parsing; it cannot authenticate. name: "personal access token (classic) with bearer lowercase", authHeader: "bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", expectedStatusCode: http.StatusOK, @@ -62,7 +62,7 @@ func TestExtractUserToken(t *testing.T) { expectTokenInfo: true, }, // Fine-grained Personal Access Token - github_pat_ prefix - { + { //nolint:gosec // G101: Synthetic token fixture exercises fine-grained PAT prefix detection; it cannot authenticate. name: "fine-grained personal access token with Bearer prefix", authHeader: "Bearer github_pat_xxxxxxxxxxxxxxxxxxxxxxx", expectedStatusCode: http.StatusOK, @@ -70,7 +70,7 @@ func TestExtractUserToken(t *testing.T) { expectedToken: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx", expectTokenInfo: true, }, - { + { //nolint:gosec // G101: Synthetic token fixture exercises fine-grained PAT parsing without a bearer prefix; it cannot authenticate. name: "fine-grained personal access token without Bearer prefix", authHeader: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx", expectedStatusCode: http.StatusOK, @@ -113,7 +113,7 @@ func TestExtractUserToken(t *testing.T) { expectTokenInfo: true, }, // Server-to-Server GitHub App Token (installation token) - ghs_ prefix - { + { //nolint:gosec // G101: Synthetic token fixture exercises installation token prefix detection; it cannot authenticate. name: "server-to-server GitHub App token with Bearer prefix", authHeader: "Bearer ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", expectedStatusCode: http.StatusOK, diff --git a/pkg/http/oauth/oauth.go b/pkg/http/oauth/oauth.go index 77cfe8fa10..b4ceba274d 100644 --- a/pkg/http/oauth/oauth.go +++ b/pkg/http/oauth/oauth.go @@ -7,9 +7,9 @@ import ( "net/http" "strings" - "github.com/github/github-mcp-server/pkg/http/headers" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/go-chi/chi/v5" "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/oauthex" @@ -20,13 +20,12 @@ const ( OAuthProtectedResourcePrefix = "/.well-known/oauth-protected-resource" ) -// SupportedScopes lists every OAuth scope that an MCP tool may require. HTTP -// protected-resource metadata advertises this full set so clients can step up -// authorization for tools excluded from the default grant. +// SupportedScopes lists every OAuth scope that an MCP tool may require. var SupportedScopes = scopes.SupportedOAuthScopes() -// DefaultScopes are requested by stdio OAuth unless the operator explicitly -// supplies --oauth-scopes. High-risk scopes such as delete_repo require opt-in. +// DefaultScopes are advertised in protected-resource metadata and requested by +// stdio OAuth unless the operator explicitly supplies --oauth-scopes. Other +// scopes require opt-in through a per-tool authorization challenge. var DefaultScopes = scopes.DefaultOAuthScopes() // Config holds the OAuth configuration for the MCP server. @@ -128,7 +127,7 @@ func (h *AuthHandler) metadataHandler() http.Handler { Resource: resourceURL, AuthorizationServers: []string{authorizationServerURL}, ResourceName: "GitHub MCP Server", - ScopesSupported: SupportedScopes, + ScopesSupported: DefaultScopes, BearerMethodsSupported: []string{"header"}, } @@ -199,7 +198,16 @@ func (h *AuthHandler) buildResourceURL(r *http.Request, resourcePath string) str if !strings.HasPrefix(resourcePath, "/") { resourcePath = "/" + resourcePath } - return baseURL + resourcePath + return appendRawQuery(baseURL+resourcePath, r.URL.RawQuery) +} + +// appendRawQuery avoids re-encoding the resource identifier that RFC 9728 +// clients compare as an exact string. +func appendRawQuery(target, rawQuery string) string { + if rawQuery == "" { + return target + } + return target + "?" + rawQuery } // GetEffectiveHostAndScheme returns the effective host and scheme for a request. @@ -248,10 +256,13 @@ func BuildResourceMetadataURL(r *http.Request, cfg *Config, resourcePath string) suffix = resourcePath } } + metadataURL := "" if cfg != nil && cfg.BaseURL != "" { - return strings.TrimSuffix(cfg.BaseURL, "/") + OAuthProtectedResourcePrefix + suffix + metadataURL = strings.TrimSuffix(cfg.BaseURL, "/") + OAuthProtectedResourcePrefix + suffix + } else { + metadataURL = fmt.Sprintf("%s://%s%s%s", scheme, host, OAuthProtectedResourcePrefix, suffix) } - return fmt.Sprintf("%s://%s%s%s", scheme, host, OAuthProtectedResourcePrefix, suffix) + return appendRawQuery(metadataURL, r.URL.RawQuery) } func normalizeBasePath(path string) string { diff --git a/pkg/http/oauth/oauth_test.go b/pkg/http/oauth/oauth_test.go index 52baae3b6c..671a62a3fe 100644 --- a/pkg/http/oauth/oauth_test.go +++ b/pkg/http/oauth/oauth_test.go @@ -7,10 +7,11 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" - "github.com/github/github-mcp-server/pkg/http/headers" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -362,6 +363,43 @@ func TestBuildResourceMetadataURL(t *testing.T) { resourcePath: "", expectedURL: "http://api.example.com/.well-known/oauth-protected-resource", }, + { + name: "query string is preserved on base URL config", + cfg: &Config{ + BaseURL: "https://custom.example.com", + }, + setupRequest: func() *http.Request { + return httptest.NewRequest(http.MethodGet, "/mcp/x/issues?features=issue_dependencies", nil) + }, + resourcePath: "/mcp/x/issues", + expectedURL: "https://custom.example.com/.well-known/oauth-protected-resource/mcp/x/issues?features=issue_dependencies", + }, + { + name: "query string is preserved without base URL config", + cfg: &Config{}, + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/mcp?features=a,b", nil) + req.Host = "api.example.com" + return req + }, + resourcePath: "/mcp", + expectedURL: "http://api.example.com/.well-known/oauth-protected-resource/mcp?features=a,b", + }, + { + name: "raw query encoding and ordering are preserved", + cfg: &Config{ + BaseURL: "https://custom.example.com", + }, + setupRequest: func() *http.Request { + return httptest.NewRequest( + http.MethodGet, + "/mcp?features=issue_dependencies%2Cfile_blame&client=web%20ide", + nil, + ) + }, + resourcePath: "/mcp", + expectedURL: "https://custom.example.com/.well-known/oauth-protected-resource/mcp?features=issue_dependencies%2Cfile_blame&client=web%20ide", + }, } for _, tc := range tests { @@ -398,7 +436,18 @@ func TestHandleProtectedResource(t *testing.T) { host: "api.example.com", method: http.MethodGet, expectedStatusCode: http.StatusOK, - expectedScopes: SupportedScopes, + expectedScopes: []string{ + "repo", + "read:org", + "read:user", + "user:email", + "read:packages", + "write:packages", + "read:project", + "project", + "gist", + "notifications", + }, validateResponse: func(t *testing.T, body map[string]any) { t.Helper() assert.Equal(t, "GitHub MCP Server", body["resource_name"]) @@ -462,6 +511,20 @@ func TestHandleProtectedResource(t *testing.T) { assert.Equal(t, "https://api.example.com/mcp/", body["resource"]) }, }, + { + name: "path with feature query", + cfg: &Config{ + BaseURL: "https://api.example.com", + }, + path: OAuthProtectedResourcePrefix + "/mcp/x/repos?features=issue_dependencies", + 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/x/repos?features=issue_dependencies", body["resource"]) + }, + }, { name: "custom authorization server in response", cfg: &Config{ @@ -521,7 +584,12 @@ func TestHandleProtectedResource(t *testing.T) { if tc.expectedScopes != nil { scopes, ok := body["scopes_supported"].([]any) require.True(t, ok) - assert.Len(t, scopes, len(tc.expectedScopes)) + actualScopes := make([]string, len(scopes)) + for i, scope := range scopes { + actualScopes[i], ok = scope.(string) + require.True(t, ok) + } + assert.Equal(t, tc.expectedScopes, actualScopes) } } }) @@ -558,11 +626,24 @@ func TestRegisterRoutes(t *testing.T) { for _, trailingSlash := range []string{"", "/"} { route := OAuthProtectedResourcePrefix + basePath + resourcePath + trailingSlash t.Run("route:"+route, func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, route, nil) + queryRoute := route + "?features=issue_dependencies" + req := httptest.NewRequest(http.MethodGet, queryRoute, 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) + require.Equal(t, http.StatusOK, rec.Code, "GET %s should return 200", queryRoute) + + var metadata map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &metadata)) + resourcePath := resolveResourcePath( + strings.TrimPrefix(route, OAuthProtectedResourcePrefix), + "", + ) + assert.Equal( + t, + "https://api.example.com"+resourcePath+"?features=issue_dependencies", + metadata["resource"], + ) req = httptest.NewRequest(http.MethodOptions, route, nil) req.Host = "api.example.com" @@ -588,6 +669,9 @@ func TestSupportedScopes(t *testing.T) { "repo", "delete_repo", "read:org", + "admin:org", + "read:enterprise", + "admin:enterprise", "read:user", "user:email", "read:packages", @@ -603,10 +687,20 @@ func TestSupportedScopes(t *testing.T) { assert.Equal(t, expectedScopes, SupportedScopes) } -func TestDefaultScopesRequiresExplicitDeleteRepoOptIn(t *testing.T) { +func TestDefaultScopesRequireExplicitOptIn(t *testing.T) { assert.Subset(t, SupportedScopes, DefaultScopes) assert.Contains(t, SupportedScopes, "delete_repo") assert.NotContains(t, DefaultScopes, "delete_repo") + assert.Contains(t, SupportedScopes, "workflow") + assert.NotContains(t, DefaultScopes, "workflow") + assert.Contains(t, SupportedScopes, "codespace") + assert.NotContains(t, DefaultScopes, "codespace") + assert.Contains(t, SupportedScopes, "admin:org") + assert.NotContains(t, DefaultScopes, "admin:org") + assert.Contains(t, SupportedScopes, "read:enterprise") + assert.NotContains(t, DefaultScopes, "read:enterprise") + assert.Contains(t, SupportedScopes, "admin:enterprise") + assert.NotContains(t, DefaultScopes, "admin:enterprise") assert.Contains(t, DefaultScopes, "repo") } diff --git a/pkg/http/server.go b/pkg/http/server.go index cc2d23d3ac..08e76b48e7 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -13,18 +13,18 @@ import ( "syscall" "time" - "github.com/github/github-mcp-server/internal/requeststate" - 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/github/github-mcp-server/v2/internal/requeststate" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/http/middleware" + "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/lockdown" + "github.com/github/github-mcp-server/v2/pkg/observability" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/go-chi/chi/v5" ) @@ -310,13 +310,13 @@ func initGlobalToolScopeMap(t translations.TranslationHelperFunc, hostType utils } // createHTTPFeatureChecker creates a feature checker that resolves static CLI -// features plus per-request header features and insiders mode. +// features plus per-request 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)) + requestFeatures := ghcontext.GetHeaderFeatures(ctx) + features := make([]string, 0, len(enabledFeatures)+len(requestFeatures)) features = append(features, enabledFeatures...) - features = append(features, headerFeatures...) + features = append(features, requestFeatures...) 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 index a8c4e1a90b..60b0df0da3 100644 --- a/pkg/http/server_test.go +++ b/pkg/http/server_test.go @@ -11,12 +11,12 @@ import ( "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/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/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/http/middleware" + "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -106,26 +106,29 @@ func TestHTTPRouterCORSContract(t *testing.T) { ) tests := []struct { - name string - method string - path string - expectedStatus int - expectChallenge bool - expectAllowHeader bool + name string + method string + path string + requestHeaders string + expectedStatus int + expectChallenge bool + expectedAllow []string }{ { - name: "MCP preflight", - method: http.MethodOptions, - path: "/", - expectedStatus: http.StatusOK, - expectAllowHeader: true, + name: "MCP preflight", + method: http.MethodOptions, + path: "/", + requestHeaders: "content-type, mcp-method, mcp-name, mcp-param-owner, mcp-param-region", + expectedStatus: http.StatusOK, + expectedAllow: []string{"Content-Type", "Mcp-Method", "Mcp-Name", "Mcp-Param-owner", "Mcp-Param-Region"}, }, { - name: "metadata preflight", - method: http.MethodOptions, - path: "/metadata", - expectedStatus: http.StatusOK, - expectAllowHeader: true, + name: "metadata preflight", + method: http.MethodOptions, + path: "/metadata", + requestHeaders: "content-type", + expectedStatus: http.StatusOK, + expectedAllow: []string{"Content-Type"}, }, { name: "authentication challenge", @@ -166,7 +169,7 @@ func TestHTTPRouterCORSContract(t *testing.T) { req.Header.Set("Origin", "https://confer.to") if tt.method == http.MethodOptions { req.Header.Set("Access-Control-Request-Method", http.MethodPost) - req.Header.Set("Access-Control-Request-Headers", "content-type") + req.Header.Set("Access-Control-Request-Headers", tt.requestHeaders) } rec := httptest.NewRecorder() @@ -177,8 +180,8 @@ func TestHTTPRouterCORSContract(t *testing.T) { assert.Empty(t, rec.Header().Get("Access-Control-Allow-Credentials")) assert.Contains(t, rec.Header().Get("Access-Control-Expose-Headers"), "Mcp-Session-Id") assert.Contains(t, rec.Header().Get("Access-Control-Expose-Headers"), "WWW-Authenticate") - if tt.expectAllowHeader { - assert.Contains(t, rec.Header().Get("Access-Control-Allow-Headers"), "Content-Type") + for _, header := range tt.expectedAllow { + assert.Contains(t, rec.Header().Get("Access-Control-Allow-Headers"), header) } if tt.expectChallenge { assert.Equal(t, @@ -259,6 +262,46 @@ func TestOAuthChallengeMetadataRouteContracts(t *testing.T) { }) } + // Query-bearing MCP server URLs must round-trip: the challenge's + // resource_metadata URL and the served metadata document's "resource" + // must both carry the exact same query as the URL the client connects to, + // because go-sdk validates metadata.resource with exact string equality. + queryPath := "/x/repos?features=issue_dependencies" + t.Run(queryPath, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, queryPath, nil) + req.Header.Set("Origin", "https://confer.to") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + challenge := rec.Header().Get("WWW-Authenticate") + require.True(t, strings.HasPrefix(challenge, `Bearer resource_metadata="`)) + metadataURL := strings.TrimSuffix( + strings.TrimPrefix(challenge, `Bearer resource_metadata="`), + `"`, + ) + assert.Equal(t, + baseURL+"/.well-known/oauth-protected-resource/mcp/x/repos?features=issue_dependencies", + metadataURL, + ) + + metadataPaths := []string{ + strings.TrimPrefix(metadataURL, baseURL), + oauth.OAuthProtectedResourcePrefix + queryPath, + } + for _, metadataPath := range metadataPaths { + req = httptest.NewRequest(http.MethodGet, metadataPath, nil) + req.Header.Set("Origin", "https://confer.to") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var metadata map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &metadata)) + assert.Equal(t, baseURL+"/mcp"+queryPath, metadata["resource"]) + } + }) + req := httptest.NewRequest( http.MethodGet, oauth.OAuthProtectedResourcePrefix+"/mcp/unknown", @@ -331,12 +374,6 @@ func TestCreateHTTPFeatureChecker(t *testing.T) { headerFeatures: []string{github.FeatureFlagPullRequestsGranular}, wantEnabled: true, }, - { - name: "MCP Apps flag accepted from header", - flagName: github.MCPAppsFeatureFlag, - headerFeatures: []string{github.MCPAppsFeatureFlag}, - wantEnabled: true, - }, { name: "MCP Apps form deferral opt-out accepted from header", flagName: github.MCPAppsDisableFormDeferralFeatureFlag, @@ -374,8 +411,8 @@ func TestCreateHTTPFeatureChecker(t *testing.T) { wantEnabled: false, }, { - name: "insiders mode enables MCP Apps without header", - flagName: github.MCPAppsFeatureFlag, + name: "insiders mode enables CSV output without header", + flagName: github.FeatureFlagCSVOutput, insidersMode: true, wantEnabled: true, }, diff --git a/pkg/http/servercard/card.go b/pkg/http/servercard/card.go new file mode 100644 index 0000000000..9a2d3ac13b --- /dev/null +++ b/pkg/http/servercard/card.go @@ -0,0 +1,145 @@ +// Package servercard provides the GitHub MCP Server's MCP Server Card +// (SEP-2127) types and a public, no-auth HTTP handler that serves it. +// +// A Server Card is a static metadata document that describes a remote MCP +// server — its identity, repository, and HTTP transport — so clients can +// discover and connect to it before the protocol handshake. It is remote-only +// and deliberately does NOT enumerate primitives (tools, resources, prompts) +// or installable packages; those remain in the MCP Registry document +// (server.json) and runtime listing. +// +// See: +// - https://github.com/modelcontextprotocol/experimental-ext-server-card +// - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127 +package servercard + +import "net/http" + +const ( + // SchemaURL is the v1 Server Card JSON Schema URI that emitted cards + // conform to. The schema is versioned by its `vN` path segment. + SchemaURL = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json" + + // MediaType is the media type used to serve and request a Server Card. + MediaType = "application/mcp-server-card+json" + + // Path is the suffix, relative to a server's streamable-HTTP URL, at which + // MCP reserves the recommended Server Card location. A server hosted at + // `https://host/mcp` therefore serves its card at `https://host/mcp/server-card`. + Path = "/server-card" + + // DefaultRemoteURL is the streamable-HTTP endpoint of the hosted GitHub MCP + // Server on github.com. The remote repository overrides this per environment. + DefaultRemoteURL = "https://api.githubcopilot.com/mcp/" +) + +// Identity fields reused from the MCP Registry document (server.json) so the +// Server Card and the registry entry describe the same server. +const ( + serverName = "io.github.github/github-mcp-server" + serverTitle = "GitHub" + serverDescription = "Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows through natural language." + repositoryURL = "https://github.com/github/github-mcp-server" + repositorySource = "github" + // repositoryID is the github.com repository ID for github/github-mcp-server. + // It is stable across renames but changes if the repository is recreated. + repositoryID = "942771284" +) + +// ServerCard is a static metadata document describing a remote MCP server, +// suitable for pre-connection discovery. It mirrors the ServerCard interface in +// modelcontextprotocol/experimental-ext-server-card. Server Cards are +// remote-only and never carry installable packages. +type ServerCard struct { + // Schema is the Server Card JSON Schema URI this document conforms to. + Schema string `json:"$schema"` + // Name is the server name in reverse-DNS format with exactly one slash. + Name string `json:"name"` + // Version is the server version, equivalent to Implementation.version. + Version string `json:"version"` + // Description is a short, human-readable explanation of server functionality. + Description string `json:"description"` + // Title is an optional human-readable display name. + Title string `json:"title,omitempty"` + // WebsiteURL optionally links to the server's homepage or documentation. + WebsiteURL string `json:"websiteUrl,omitempty"` + // Repository optionally describes the server's source code for inspection. + Repository *Repository `json:"repository,omitempty"` + // Remotes lists the HTTP-based endpoints for connecting to the server. + Remotes []Remote `json:"remotes,omitempty"` +} + +// Repository describes the MCP server's source code location. +type Repository struct { + // URL is the repository URL for browsing source and cloning. + URL string `json:"url"` + // Source is the hosting service identifier (e.g. "github"). + Source string `json:"source"` + // ID is the optional repository identifier owned by the hosting service. + ID string `json:"id,omitempty"` +} + +// Remote describes a remote (HTTP-based) MCP server endpoint. Authentication is +// intentionally not described here: the hosted server advertises its auth +// requirements via OAuth protected-resource-metadata discovery, so duplicating +// them on the card would risk drift and cannot capture every accepted mode. +type Remote struct { + // Type is the transport type ("streamable-http" or "sse"). + Type string `json:"type"` + // URL is the endpoint URL. + URL string `json:"url"` +} + +// Config controls how the GitHub MCP Server card is built and served. +type Config struct { + // Version is advertised as the card's version and SHOULD match the + // runtime serverInfo version. When empty, "0.0.0-dev" is used. + Version string + + // RemoteURL is the absolute streamable-HTTP endpoint advertised in the + // card's single remote. When empty, DefaultRemoteURL is used. The remote + // repository supplies a per-environment URL here. + RemoteURL string + + // RemoteURLFunc, when set, derives the streamable-HTTP remote URL from the + // incoming request, taking precedence over RemoteURL whenever it returns a + // non-empty value. This supports multi-tenant deployments (e.g. proxima) + // where the absolute URL varies per request (e.g. from X-Forwarded-Host). + // + // It is consumed by the Handler when serving a card; NewServerCard ignores + // it, since the card constructor is not request-aware. + RemoteURLFunc func(*http.Request) string +} + +// NewServerCard builds the GitHub MCP Server's Server Card from cfg. +func NewServerCard(cfg Config) *ServerCard { + version := cfg.Version + if version == "" { + version = "0.0.0-dev" + } + + remoteURL := cfg.RemoteURL + if remoteURL == "" { + remoteURL = DefaultRemoteURL + } + + // supportedProtocolVersions is intentionally omitted: the go-sdk does not + // export the versions it negotiates, so advertising a hand-maintained list + // here would risk drifting from what the server actually serves. + return &ServerCard{ + Schema: SchemaURL, + Name: serverName, + Version: version, + Description: serverDescription, + Title: serverTitle, + WebsiteURL: repositoryURL, + Repository: &Repository{ + URL: repositoryURL, + Source: repositorySource, + ID: repositoryID, + }, + Remotes: []Remote{ + {Type: "streamable-http", URL: remoteURL}, + }, + } +} diff --git a/pkg/http/servercard/card_test.go b/pkg/http/servercard/card_test.go new file mode 100644 index 0000000000..c10c08831c --- /dev/null +++ b/pkg/http/servercard/card_test.go @@ -0,0 +1,96 @@ +package servercard + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// assertCardContract checks the required Server Card fields defined by the +// experimental-ext-server-card v1 schema, plus the remote-only invariant. The +// card is small and stable, so asserting its required fields is a focused +// stand-in for vendoring the upstream JSON schema. +func assertCardContract(t *testing.T, card *ServerCard) { + t.Helper() + + raw, err := json.Marshal(card) + require.NoError(t, err) + + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &fields)) + + // Required by the schema: $schema, name, version, description. + assert.Equal(t, SchemaURL, card.Schema) + assert.NotEmpty(t, card.Name) + assert.NotEmpty(t, card.Version) + require.NotEmpty(t, card.Description) + assert.LessOrEqual(t, len(card.Description), 100, "description must respect the schema maxLength") + for _, key := range []string{"$schema", "name", "version", "description"} { + assert.Contains(t, fields, key, "required field %q must be serialized", key) + } + + // Remote-only: a Server Card never enumerates installable packages — those + // stay in the registry server.json. + assert.NotContains(t, fields, "packages", "Server Card must be remote-only and omit packages") + require.Len(t, card.Remotes, 1) + assert.Equal(t, "streamable-http", card.Remotes[0].Type) +} + +func TestNewServerCard(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg Config + expectedVersion string + expectedRemoteURL string + }{ + { + name: "defaults", + cfg: Config{}, + expectedVersion: "0.0.0-dev", + expectedRemoteURL: DefaultRemoteURL, + }, + { + name: "explicit version", + cfg: Config{Version: "1.2.3"}, + expectedVersion: "1.2.3", + expectedRemoteURL: DefaultRemoteURL, + }, + { + name: "per-environment remote URL", + cfg: Config{Version: "1.2.3", RemoteURL: "https://api.example.test/mcp/"}, + expectedVersion: "1.2.3", + expectedRemoteURL: "https://api.example.test/mcp/", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + card := NewServerCard(tc.cfg) + + // Identity is reused from the registry document (server.json): it is + // the stable Server Card / registry server name. The AI Catalog + // identifier is assigned independently and is not derived from it. + assert.Equal(t, "io.github.github/github-mcp-server", card.Name) + assert.Equal(t, "GitHub", card.Title) + assert.True(t, strings.HasPrefix(card.Description, "Connect AI assistants to GitHub")) + assert.Equal(t, tc.expectedVersion, card.Version) + assert.Equal(t, "https://github.com/github/github-mcp-server", card.WebsiteURL) + + require.NotNil(t, card.Repository) + assert.Equal(t, "https://github.com/github/github-mcp-server", card.Repository.URL) + assert.Equal(t, "github", card.Repository.Source) + assert.Equal(t, "942771284", card.Repository.ID) + + assert.Equal(t, tc.expectedRemoteURL, card.Remotes[0].URL) + + assertCardContract(t, card) + }) + } +} diff --git a/pkg/http/servercard/handler.go b/pkg/http/servercard/handler.go new file mode 100644 index 0000000000..f081357e43 --- /dev/null +++ b/pkg/http/servercard/handler.go @@ -0,0 +1,241 @@ +package servercard + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/go-chi/chi/v5" +) + +// Handler serves the GitHub MCP Server's Server Card over HTTP as public, +// no-auth metadata. It mirrors the OAuth protected-resource-metadata handler so +// the remote server repository can mount it and supply a per-environment remote +// URL via Config. +type Handler struct { + cfg Config +} + +// NewHandler returns a Handler that serves the card built from cfg. +func NewHandler(cfg Config) *Handler { + return &Handler{cfg: cfg} +} + +// RegisterRoutes mounts the handler at the single canonical Path for every +// method (mirroring oauth.AuthHandler) so it owns the route — answering non-GET +// requests itself rather than falling through to the auth-gated MCP endpoint — +// and is deliberately exposed at no alternate path. +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Handle(Path, h) +} + +// ServeHTTP serves the Server Card as application/mcp-server-card+json. +// +// It honors GET and HEAD (with OPTIONS preflight), performs content negotiation +// against the Accept header, supports ETag conditional requests, and is safe to +// mount at /server-card without authentication middleware. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodOptions: + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + case http.MethodGet, http.MethodHead: + // served below + default: + setCORSHeaders(w) + w.Header().Set("Allow", "GET, HEAD, OPTIONS") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if !acceptsCard(joinFieldLines(r.Header.Values(headers.AcceptHeader))) { + setCORSHeaders(w) + http.Error(w, "not acceptable: expected "+MediaType, http.StatusNotAcceptable) + return + } + + ServeCard(w, r, h.resolveCard(r)) +} + +// resolveCard builds the card for a request, applying the per-request +// RemoteURLFunc override when configured. +func (h *Handler) resolveCard(r *http.Request) *ServerCard { + cfg := h.cfg + if h.cfg.RemoteURLFunc != nil { + if url := h.cfg.RemoteURLFunc(r); url != "" { + cfg.RemoteURL = url + } + } + return NewServerCard(cfg) +} + +// ServeCard writes card to w as the canonical Server Card response and is the +// single source of truth for its headers and conditional-request behavior, so +// callers that build a card per request get byte-identical ETag and headers. +// +// It sets the CORS headers, a one-hour Cache-Control, and a strong ETag. A +// matching If-None-Match (strong, weak, or `*`) yields 304; HEAD omits the body. +// Callers handle method dispatch and Accept negotiation first. +func ServeCard(w http.ResponseWriter, r *http.Request, card *ServerCard) { + body, err := json.Marshal(card) + if err != nil { + setCORSHeaders(w) + http.Error(w, "failed to encode server card", http.StatusInternalServerError) + return + } + + etag := computeETag(body) + + setCORSHeaders(w) + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("ETag", etag) + + if ifNoneMatchSatisfied(joinFieldLines(r.Header.Values("If-None-Match")), etag) { + w.WriteHeader(http.StatusNotModified) + return + } + + w.Header().Set(headers.ContentTypeHeader, MediaType) + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodHead { + return + } + _, _ = w.Write(body) +} + +// computeETag returns a strong ETag: the lowercase-hex SHA-256 of body, wrapped +// in double quotes. It is deterministic for identical content. +func computeETag(body []byte) string { + sum := sha256.Sum256(body) + return `"` + hex.EncodeToString(sum[:]) + `"` +} + +// ifNoneMatchSatisfied reports whether an If-None-Match header value matches the +// given strong ETag using RFC 9110 weak comparison: `*` always matches, and a +// listed entity-tag matches if its opaque tag equals etag's, ignoring any weak +// `W/` prefix. +func ifNoneMatchSatisfied(ifNoneMatch, etag string) bool { + ifNoneMatch = strings.TrimSpace(ifNoneMatch) + if ifNoneMatch == "" { + return false + } + if ifNoneMatch == "*" { + return true + } + + target := strings.TrimPrefix(etag, "W/") + for _, candidate := range splitList(ifNoneMatch, ',', false) { + if strings.TrimPrefix(strings.TrimSpace(candidate), "W/") == target { + return true + } + } + return false +} + +// setCORSHeaders applies the read-only CORS + caching headers required by the +// experimental-ext-server-card discovery spec. The card is public metadata, so +// any origin may read it. Because the response is publicly cacheable and the +// remote URL derives from the trusted X-Forwarded-Host, Vary declares Accept and +// X-Forwarded-Host so a shared cache cannot serve one tenant's card to another; +// it is added (not set) to preserve values contributed by upstream middleware. +func setCORSHeaders(w http.ResponseWriter) { + h := w.Header() + h.Set("Access-Control-Allow-Origin", "*") + h.Set("Access-Control-Allow-Methods", "GET") + h.Set("Access-Control-Allow-Headers", "Content-Type, If-None-Match") + h.Set("Access-Control-Expose-Headers", "ETag") + h.Add("Vary", "Accept, X-Forwarded-Host") +} + +// joinFieldLines merges a list-valued header's repeated field-lines, since net/http's Header.Get returns only the first. +func joinFieldLines(values []string) string { + return strings.Join(values, ",") +} + +// acceptsCard reports whether an Accept header value permits the Server Card +// media type, honoring RFC 9110 quality values. An empty header accepts +// anything. Otherwise the most specific matching media range decides the +// result — the exact card type over application/* over */* — and an explicit +// q=0 on that range rejects the representation (yielding 406). A range that +// carries a media parameter (one appearing before q) does not match the +// parameterless card and is skipped. +func acceptsCard(accept string) bool { + if strings.TrimSpace(accept) == "" { + return true + } + + bestSpecificity := -1 + var bestQuality float64 + for _, part := range splitList(accept, ',', true) { + mediaRange, quality, hasParams := parseMediaRange(part) + specificity := -1 + if !hasParams { + switch mediaRange { + case MediaType: + specificity = 2 + case "application/*": + specificity = 1 + case "*/*": + specificity = 0 + } + } + if specificity > bestSpecificity { + bestSpecificity = specificity + bestQuality = quality + } + } + return bestSpecificity >= 0 && bestQuality > 0 +} + +// parseMediaRange splits one Accept media range into its lowercased media type, +// quality value, and whether it carries a media parameter. Parameters up to the +// first q are media parameters (which the parameterless card cannot satisfy, so +// hasParams is set); q sets the quality (default 1.0); segments after q are +// accept extensions and are ignored. +func parseMediaRange(part string) (mediaType string, quality float64, hasParams bool) { + segments := splitList(part, ';', true) + mediaType = strings.ToLower(strings.TrimSpace(segments[0])) + quality = 1.0 + seenQ := false + for _, seg := range segments[1:] { + name, value, _ := strings.Cut(seg, "=") + if strings.EqualFold(strings.TrimSpace(name), "q") { + seenQ = true + if q, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil { + quality = q + } + continue + } + if !seenQ && strings.TrimSpace(name) != "" { + hasParams = true + } + } + return mediaType, quality, hasParams +} + +// splitList splits an HTTP list value on sep, honoring RFC 9110 quoted strings so a +// quoted separator does not divide the list. escape enables backslash quoted-pairs +// (Accept parameter values); entity-tags pass false and treat backslash literally. +func splitList(s string, sep byte, escape bool) []string { + var out []string + start, inQuote, esc := 0, false, false + for i := range len(s) { + switch { + case esc: + esc = false + case inQuote && escape && s[i] == '\\': + esc = true + case s[i] == '"': + inQuote = !inQuote + case s[i] == sep && !inQuote: + out = append(out, s[start:i]) + start = i + 1 + } + } + return append(out, s[start:]) +} diff --git a/pkg/http/servercard/handler_test.go b/pkg/http/servercard/handler_test.go new file mode 100644 index 0000000000..57d16e9bb7 --- /dev/null +++ b/pkg/http/servercard/handler_test.go @@ -0,0 +1,318 @@ +package servercard + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// assertCanonicalCORS asserts the CORS and cache-negotiation headers the +// discovery spec mandates on every card response (errors, preflight, and 304s). +func assertCanonicalCORS(t *testing.T, res *http.Response) { + t.Helper() + h := res.Header + assert.Equal(t, "*", h.Get("Access-Control-Allow-Origin")) + assert.Equal(t, "GET", h.Get("Access-Control-Allow-Methods")) + assert.Equal(t, "Content-Type, If-None-Match", h.Get("Access-Control-Allow-Headers")) + assert.Equal(t, "ETag", h.Get("Access-Control-Expose-Headers")) + assert.Equal(t, "Accept, X-Forwarded-Host", h.Get("Vary")) +} + +// assertStrongETag asserts a quoted, strong SHA-256 ETag and returns it. +func assertStrongETag(t *testing.T, res *http.Response) string { + t.Helper() + etag := res.Header.Get("ETag") + assert.True(t, strings.HasPrefix(etag, `"`) && strings.HasSuffix(etag, `"`), "ETag must be a quoted strong tag, got %q", etag) + assert.Len(t, etag, 66, "ETag should wrap a 64-char hex SHA-256 in quotes") + return etag +} + +func TestHandlerServeHTTP(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + accept string + expectedStatus int + expectBody bool + }{ + { + name: "GET returns the card", + method: http.MethodGet, + expectedStatus: http.StatusOK, + expectBody: true, + }, + { + name: "GET with incompatible Accept is rejected", + method: http.MethodGet, + accept: "text/html", + expectedStatus: http.StatusNotAcceptable, + }, + { + name: "HEAD returns headers without body", + method: http.MethodHead, + expectedStatus: http.StatusOK, + expectBody: false, + }, + { + name: "OPTIONS preflight", + method: http.MethodOptions, + expectedStatus: http.StatusOK, + }, + { + name: "POST is not allowed", + method: http.MethodPost, + expectedStatus: http.StatusMethodNotAllowed, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + handler := NewHandler(Config{Version: "1.2.3"}) + req := httptest.NewRequest(tc.method, Path, nil) + if tc.accept != "" { + req.Header.Set(headers.AcceptHeader, tc.accept) + } + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + res := rec.Result() + defer res.Body.Close() + + assert.Equal(t, tc.expectedStatus, res.StatusCode) + assertCanonicalCORS(t, res) + + if tc.expectedStatus == http.StatusOK && tc.method != http.MethodOptions { + assert.Equal(t, MediaType, res.Header.Get(headers.ContentTypeHeader)) + assertStrongETag(t, res) + } + + if tc.expectBody { + var card ServerCard + require.NoError(t, json.NewDecoder(res.Body).Decode(&card)) + assert.Equal(t, SchemaURL, card.Schema) + assert.Equal(t, "1.2.3", card.Version) + require.Len(t, card.Remotes, 1) + assert.Equal(t, DefaultRemoteURL, card.Remotes[0].URL) + } + }) + } +} + +// TestHandlerMultiLineAccept guards the list-valued-header fix: the handler joins +// Accept's repeated field-lines and negotiates them as one list (Header.Get would +// read only the first line and 406). +func TestHandlerMultiLineAccept(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, Path, nil) + req.Header.Add(headers.AcceptHeader, "text/html") + req.Header.Add(headers.AcceptHeader, MediaType) + rec := httptest.NewRecorder() + + NewHandler(Config{}).ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Result().StatusCode) +} + +// TestAcceptsCard exercises RFC 9110 Accept negotiation directly, covering quoted +// commas/escapes, pre-q media parameters, post-q extensions, and specificity/q=0. +func TestAcceptsCard(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + accept string + want bool + }{ + {name: "empty accepts anything", accept: "", want: true}, + {name: "exact type", accept: MediaType, want: true}, + {name: "exact type refused", accept: MediaType + ";q=0", want: false}, + {name: "full wildcard", accept: "*/*", want: true}, + {name: "full wildcard refused", accept: "*/*;q=0", want: false}, + {name: "application wildcard", accept: "application/*", want: true}, + {name: "application wildcard refused", accept: "application/*;q=0", want: false}, + {name: "unrelated type", accept: "text/html", want: false}, + {name: "quoted comma in refused param", accept: `application/mcp-server-card+json;note="a,b";q=0`, want: false}, + {name: "escaped quote in refused param", accept: `application/mcp-server-card+json;note="a\",b";q=0`, want: false}, + {name: "pre-q param falls through to wildcard", accept: "application/mcp-server-card+json;profile=x;q=0, */*;q=1", want: true}, + {name: "pre-q param without fallback does not match", accept: "application/mcp-server-card+json;profile=x", want: false}, + {name: "post-q extension is ignored", accept: "application/mcp-server-card+json;q=1;ext=foo", want: true}, + {name: "post-q extension on a refused range", accept: "application/mcp-server-card+json;q=0;ext=foo", want: false}, + {name: "most specific refusal wins over broad accept", accept: "application/mcp-server-card+json;q=0, */*;q=1", want: false}, + {name: "specific accept wins over broad refusal", accept: "application/mcp-server-card+json, */*;q=0", want: true}, + {name: "quoted wildcard param does not spoof a match", accept: `application/mcp-server-card+json;x="*/*";q=0`, want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, acceptsCard(tc.accept)) + }) + } +} + +// TestHandlerVaryComposition asserts the handler appends Accept to Vary without +// discarding values a deployment's middleware set earlier in the chain. +func TestHandlerVaryComposition(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, Path, nil) + rec := httptest.NewRecorder() + rec.Header().Set("Vary", "Origin") + + NewHandler(Config{}).ServeHTTP(rec, req) + + assert.Equal(t, []string{"Origin", "Accept, X-Forwarded-Host"}, rec.Result().Header.Values("Vary")) +} + +func TestHandlerRegisterRoutes(t *testing.T) { + t.Parallel() + + // The remote mounts the MCP endpoint as a catch-all at "/" (mirroring + // pkg/http/handler.go: r.Mount("/", h)). The card's static route must take + // precedence so the card — not the auth-gated MCP endpoint — answers its + // single canonical path, with no alternate (e.g. /mcp/server-card). + const mcpStatus = http.StatusUnauthorized // sentinel for the auth-gated MCP catch-all + + r := chi.NewRouter() + r.Group(func(r chi.Router) { + r.Mount("/", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(mcpStatus) + })) + }) + r.Group(func(r chi.Router) { + NewHandler(Config{}).RegisterRoutes(r) + }) + + tests := []struct { + name string + method string + path string + wantStatus int + wantCard bool + }{ + {name: "canonical GET is served by the card, not the catch-all", method: http.MethodGet, path: Path, wantStatus: http.StatusOK, wantCard: true}, + {name: "non-GET at the card path is the card's 405, not the catch-all", method: http.MethodPost, path: Path, wantStatus: http.StatusMethodNotAllowed}, + {name: "no alternate path is registered", method: http.MethodGet, path: "/mcp" + Path, wantStatus: mcpStatus}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(tc.method, tc.path, nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + res := rec.Result() + defer res.Body.Close() + + assert.Equal(t, tc.wantStatus, res.StatusCode) + if tc.wantCard { + assert.Equal(t, MediaType, res.Header.Get(headers.ContentTypeHeader)) + } + }) + } +} + +func TestHandlerETagConditionalRequests(t *testing.T) { + t.Parallel() + + handler := NewHandler(Config{Version: "1.2.3"}) + + get := func(t *testing.T, ifNoneMatch string) *http.Response { + t.Helper() + req := httptest.NewRequest(http.MethodGet, Path, nil) + if ifNoneMatch != "" { + req.Header.Set("If-None-Match", ifNoneMatch) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Result() + } + + // Baseline GET yields a quoted strong ETag; every table case re-asserts it, proving stability. + res := get(t, "") + etag := assertStrongETag(t, res) + res.Body.Close() + + tests := []struct { + name string + ifNoneMatch string + expectedStatus int + expectBody bool + }{ + {name: "matching strong tag", ifNoneMatch: etag, expectedStatus: http.StatusNotModified, expectBody: false}, + {name: "matching weak form", ifNoneMatch: "W/" + etag, expectedStatus: http.StatusNotModified, expectBody: false}, + {name: "wildcard", ifNoneMatch: "*", expectedStatus: http.StatusNotModified, expectBody: false}, + {name: "within a list", ifNoneMatch: `"other", ` + etag, expectedStatus: http.StatusNotModified, expectBody: false}, + {name: "list with backslash in tag", ifNoneMatch: `"o\", ` + etag, expectedStatus: http.StatusNotModified, expectBody: false}, + {name: "non-matching tag", ifNoneMatch: `"deadbeef"`, expectedStatus: http.StatusOK, expectBody: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + res := get(t, tc.ifNoneMatch) + defer res.Body.Close() + + assert.Equal(t, tc.expectedStatus, res.StatusCode) + assert.Equal(t, etag, res.Header.Get("ETag")) + assert.Equal(t, "public, max-age=3600", res.Header.Get("Cache-Control")) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + if tc.expectBody { + assert.NotEmpty(t, body) + assert.Equal(t, MediaType, res.Header.Get(headers.ContentTypeHeader)) + } else { + assert.Empty(t, body, "304 must have an empty body") + assert.Empty(t, res.Header.Get(headers.ContentTypeHeader), "304 should not carry Content-Type") + } + }) + } +} + +func TestHandlerRemoteURLFunc(t *testing.T) { + t.Parallel() + + // Simulate a multi-tenant deployment deriving the remote URL per request. + handler := NewHandler(Config{ + Version: "1.2.3", + RemoteURLFunc: func(r *http.Request) string { + return "https://" + r.Host + "/mcp/" + }, + }) + + fetch := func(host string) (string, string) { + req := httptest.NewRequest(http.MethodGet, Path, nil) + req.Host = host + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + res := rec.Result() + defer res.Body.Close() + var card ServerCard + require.NoError(t, json.NewDecoder(res.Body).Decode(&card)) + require.Len(t, card.Remotes, 1) + return card.Remotes[0].URL, res.Header.Get("ETag") + } + + urlA, etagA := fetch("tenant-a.example.test") + urlB, etagB := fetch("tenant-b.example.test") + + assert.Equal(t, "https://tenant-a.example.test/mcp/", urlA) + assert.Equal(t, "https://tenant-b.example.test/mcp/", urlB) + assert.NotEqual(t, etagA, etagB, "different per-tenant bodies must yield different ETags") +} diff --git a/pkg/http/transport/bearer.go b/pkg/http/transport/bearer.go index 522f4c6753..ff0049a238 100644 --- a/pkg/http/transport/bearer.go +++ b/pkg/http/transport/bearer.go @@ -4,8 +4,8 @@ import ( "net/http" "strings" - ghcontext "github.com/github/github-mcp-server/pkg/context" - headers "github.com/github/github-mcp-server/pkg/http/headers" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + headers "github.com/github/github-mcp-server/v2/pkg/http/headers" ) type BearerAuthTransport struct { diff --git a/pkg/http/transport/bearer_test.go b/pkg/http/transport/bearer_test.go index 49f50710d0..46f2a51375 100644 --- a/pkg/http/transport/bearer_test.go +++ b/pkg/http/transport/bearer_test.go @@ -7,8 +7,8 @@ import ( "net/url" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/headers" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/headers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/http/transport/etag.go b/pkg/http/transport/etag.go index 152bc33429..7af7611d2f 100644 --- a/pkg/http/transport/etag.go +++ b/pkg/http/transport/etag.go @@ -11,7 +11,7 @@ import ( "strings" "sync" - "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/headers" ) const ( diff --git a/pkg/http/transport/etag_test.go b/pkg/http/transport/etag_test.go index a3905d01ee..36b6613f09 100644 --- a/pkg/http/transport/etag_test.go +++ b/pkg/http/transport/etag_test.go @@ -9,7 +9,7 @@ import ( "sync/atomic" "testing" - "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/headers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -46,10 +46,10 @@ func TestETagTransport_ServesCachedBodyOn304(t *testing.T) { const etag = `"abc123"` const body = `{"number":1}` - var requests int32 + var requests atomic.Int32 var lastIfNoneMatch string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := atomic.AddInt32(&requests, 1) + n := requests.Add(1) lastIfNoneMatch = r.Header.Get(headers.IfNoneMatchHeader) w.Header().Set(headers.ETagHeader, etag) if n == 1 { @@ -84,7 +84,7 @@ func TestETagTransport_ServesCachedBodyOn304(t *testing.T) { assert.Equal(t, http.StatusOK, status2, "304 is translated to the cached 200") assert.Equal(t, body, body2, "cached body is served on 304") assert.Equal(t, etag, lastIfNoneMatch, "second request sends the cached ETag") - assert.Equal(t, int32(2), atomic.LoadInt32(&requests), "every request still reaches the server") + assert.Equal(t, int32(2), requests.Load(), "every request still reaches the server") } // TestETagTransport_UpdatesRateLimitHeadersFrom304 verifies that a cache-served @@ -95,9 +95,9 @@ func TestETagTransport_UpdatesRateLimitHeadersFrom304(t *testing.T) { const etag = `"v1"` - var requests int32 + var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - n := atomic.AddInt32(&requests, 1) + n := requests.Add(1) w.Header().Set(headers.ETagHeader, etag) if n == 1 { w.Header().Set("X-RateLimit-Remaining", "100") @@ -143,7 +143,7 @@ func TestETagTransport_ScopesCacheByAuthorization(t *testing.T) { return } w.WriteHeader(http.StatusOK) - _, _ = io.WriteString(w, r.Header.Get(headers.AuthorizationHeader)) + _, _ = io.WriteString(w, r.Header.Get(headers.AuthorizationHeader)) //nolint:gosec // G705: Test server echoes synthetic tokens to verify cache isolation; no browser consumes the response. })) defer server.Close() diff --git a/pkg/http/transport/graphql_features.go b/pkg/http/transport/graphql_features.go index 7fe9182fcb..b22f9eb909 100644 --- a/pkg/http/transport/graphql_features.go +++ b/pkg/http/transport/graphql_features.go @@ -4,8 +4,8 @@ import ( "net/http" "strings" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/headers" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/headers" ) // GraphQLFeaturesTransport is an http.RoundTripper that adds GraphQL-Features @@ -18,7 +18,7 @@ import ( // // Usage: // -// import "github.com/github/github-mcp-server/pkg/http/transport" +// import "github.com/github/github-mcp-server/v2/pkg/http/transport" // // httpClient := &http.Client{ // Transport: &transport.GraphQLFeaturesTransport{ diff --git a/pkg/http/transport/graphql_features_test.go b/pkg/http/transport/graphql_features_test.go index 8c814fd6f2..d4c3b74ce5 100644 --- a/pkg/http/transport/graphql_features_test.go +++ b/pkg/http/transport/graphql_features_test.go @@ -6,8 +6,8 @@ import ( "net/http/httptest" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/headers" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/headers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/http/transport/user_agent.go b/pkg/http/transport/user_agent.go index a489941cce..0bb82eb31e 100644 --- a/pkg/http/transport/user_agent.go +++ b/pkg/http/transport/user_agent.go @@ -1,11 +1,19 @@ package transport import ( + "context" "net/http" - "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/headers" ) +type userAgentKey struct{} + +// WithUserAgent supplies a request-scoped identity without mutating a shared transport. +func WithUserAgent(ctx context.Context, agent string) context.Context { + return context.WithValue(ctx, userAgentKey{}, agent) +} + type UserAgentTransport struct { Transport http.RoundTripper Agent string @@ -13,6 +21,10 @@ type UserAgentTransport struct { func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) - req.Header.Set(headers.UserAgentHeader, t.Agent) + agent := t.Agent + if scoped, ok := req.Context().Value(userAgentKey{}).(string); ok { + agent = scoped + } + req.Header.Set(headers.UserAgentHeader, agent) return t.Transport.RoundTrip(req) } diff --git a/pkg/http/transport/user_agent_test.go b/pkg/http/transport/user_agent_test.go new file mode 100644 index 0000000000..9757b4937f --- /dev/null +++ b/pkg/http/transport/user_agent_test.go @@ -0,0 +1,48 @@ +package transport + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserAgentTransportRequestIsolation(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _, _ = io.WriteString(w, req.UserAgent()) //nolint:gosec // G705: Test server echoes the test client's user agent to verify transport isolation; no browser consumes the response. + })) + t.Cleanup(server.Close) + transport := &UserAgentTransport{ + Transport: http.DefaultTransport, + Agent: "github-mcp-server/remote-abcdef", + } + client := &http.Client{Transport: transport} + + for _, agent := range []string{"", "github-mcp-server/v1.2.3 (first/1)", "github-mcp-server/v1.2.3 (second/2)"} { + t.Run(agent, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + want := transport.Agent + if agent != "" { + ctx = WithUserAgent(ctx, agent) + want = agent + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil) + require.NoError(t, err) + req.Header.Set("User-Agent", "original") + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, want, string(body)) + assert.Equal(t, "original", req.UserAgent()) + assert.Equal(t, "github-mcp-server/remote-abcdef", transport.Agent) + }) + } +} diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index 9ecaca1f57..e70df0eed3 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -14,11 +14,6 @@ var ( ErrUnknownTools = errors.New("unknown tools specified in WithTools") ) -// mcpAppsFeatureFlag is the feature flag name that controls MCP Apps UI metadata. -// This is defined here to avoid importing pkg/github (which imports pkg/inventory). -// The value must match github.MCPAppsFeatureFlag. -const mcpAppsFeatureFlag = "remote_mcp_ui_apps" - // ToolFilter is a function that determines if a tool should be included. // Returns true if the tool should be included, false to exclude it. type ToolFilter func(ctx context.Context, tool *ServerTool) (bool, error) @@ -65,19 +60,19 @@ func NewBuilder() *Builder { // SetTools sets the tools for the inventory. Returns self for chaining. func (b *Builder) SetTools(tools []ServerTool) *Builder { - b.tools = tools + b.tools = slices.Clone(tools) return b } // SetResources sets the resource templates for the inventory. Returns self for chaining. func (b *Builder) SetResources(resources []ServerResourceTemplate) *Builder { - b.resourceTemplates = resources + b.resourceTemplates = slices.Clone(resources) return b } // SetPrompts sets the prompts for the inventory. Returns self for chaining. func (b *Builder) SetPrompts(prompts []ServerPrompt) *Builder { - b.prompts = prompts + b.prompts = slices.Clone(prompts) return b } @@ -125,15 +120,10 @@ func (b *Builder) WithTools(toolNames []string) *Builder { return b } -// WithFeatureChecker sets the feature flag checker function. -// The checker receives a context (for actor extraction) and feature flag name, -// 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. +// WithFeatureChecker sets the feature flag checker function. Inventory items +// declare their feature dependencies and functional availability rules through +// FeatureRule. Checks are deduplicated into request-owned resolution state; +// errors are logged and treated as disabled. // // When the checker is nil, no feature-flag filter is installed; tools, // resources, and prompts pass through feature-flag gating unchanged. The @@ -212,15 +202,7 @@ func cleanTools(tools []string) []string { 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: tools, @@ -387,16 +369,21 @@ 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. +// mcpAppsMetaKeys lists the Meta keys that carry MCP Apps UI metadata. 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. +// client does not support MCP Apps UI. App-only tools (ui.visibility without +// "model") are omitted entirely rather than being exposed as ordinary +// model-visible tools. func stripMCPAppsMetadata(tools []ServerTool) []ServerTool { result := make([]ServerTool, 0, len(tools)) for _, tool := range tools { + if isAppOnlyTool(&tool) { + continue + } if stripped := stripMetaKeys(tool, mcpAppsMetaKeys); stripped != nil { result = append(result, *stripped) } else { @@ -406,6 +393,30 @@ func stripMCPAppsMetadata(tools []ServerTool) []ServerTool { return result } +// isAppOnlyTool reports whether a tool declares MCP Apps visibility that +// excludes the model (e.g. `_meta.ui.visibility: ["app"]`). Such tools are +// only meant to be called by MCP App views, never by the model directly. +func isAppOnlyTool(tool *ServerTool) bool { + ui, ok := tool.Tool.Meta["ui"].(map[string]any) + if !ok { + return false + } + var visibility []string + switch v := ui["visibility"].(type) { + case []string: + visibility = v + case []any: + for _, item := range v { + if s, ok := item.(string); ok { + visibility = append(visibility, s) + } + } + default: + return false + } + return len(visibility) > 0 && !slices.Contains(visibility, "model") +} + // 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 { diff --git a/pkg/inventory/encoded_schemas.go b/pkg/inventory/encoded_schemas.go new file mode 100644 index 0000000000..16a13df96e --- /dev/null +++ b/pkg/inventory/encoded_schemas.go @@ -0,0 +1,93 @@ +package inventory + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type listedToolSchemas struct { + source any + outputSource any + registered *mcp.Tool +} + +type encodedSchemaKey struct { + schema *jsonschema.Schema + // Input schemas receive header annotations at registration; output schemas do not. + input bool +} + +var encodedSchemas sync.Map // encodedSchemaKey -> func() (json.RawMessage, error) + +func encodeListedSchema(source, registered, listed any, input bool) (any, error) { + schema, ok := source.(*jsonschema.Schema) + if !ok || schema == nil { + return listed, nil + } + finalSchema, ok := registered.(*jsonschema.Schema) + if !ok || finalSchema == nil { + return listed, nil + } + if listedSchema, ok := listed.(*jsonschema.Schema); !ok || listedSchema != finalSchema { + // Another middleware or a replacement tool may provide a different schema. + return listed, nil + } + + key := encodedSchemaKey{schema: schema, input: input} + encode, ok := encodedSchemas.Load(key) + if !ok { + // Key by the source definition: header annotation clones the input schema + // on each HTTP registration, but its encoded representation is unchanged. + encode, _ = encodedSchemas.LoadOrStore(key, sync.OnceValues(func() (json.RawMessage, error) { + data, err := json.Marshal(finalSchema) + return json.RawMessage(data), err + })) + } + return encode.(func() (json.RawMessage, error))() +} + +func encodedToolSchemasMiddleware(schemas map[string]listedToolSchemas) mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + result, err := next(ctx, method, request) + if err != nil || method != MCPMethodToolsList { + return result, err + } + list, ok := result.(*mcp.ListToolsResult) + if !ok || list == nil { + return result, nil + } + patched := *list + if list.Tools != nil { + patched.Tools = make([]*mcp.Tool, len(list.Tools)) + } + for i, tool := range list.Tools { + definition, ok := schemas[tool.Name] + if !ok { + patched.Tools[i] = tool + continue + } + toolCopy := *tool + toolCopy.InputSchema, err = encodeListedSchema(definition.source, definition.registered.InputSchema, tool.InputSchema, true) + if err != nil { + return nil, fmt.Errorf("encode input schema for %q: %w", tool.Name, err) + } + outputSource := definition.outputSource + if outputSource == nil { + outputSource = definition.registered.OutputSchema + } + toolCopy.OutputSchema, err = encodeListedSchema(outputSource, definition.registered.OutputSchema, tool.OutputSchema, false) + if err != nil { + return nil, fmt.Errorf("encode output schema for %q: %w", tool.Name, err) + } + patched.Tools[i] = &toolCopy + } + return &patched, nil + } + } +} diff --git a/pkg/inventory/encoded_schemas_test.go b/pkg/inventory/encoded_schemas_test.go new file mode 100644 index 0000000000..c3442878bc --- /dev/null +++ b/pkg/inventory/encoded_schemas_test.go @@ -0,0 +1,300 @@ +package inventory + +import ( + "context" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "testing" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func listThroughEncoder(t *testing.T, definitions map[string]listedToolSchemas, list *mcp.ListToolsResult) *mcp.ListToolsResult { + t.Helper() + handler := encodedToolSchemasMiddleware(definitions)(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return list, nil + }) + result, err := handler(context.Background(), MCPMethodToolsList, nil) + require.NoError(t, err) + return result.(*mcp.ListToolsResult) +} + +func TestEncodedSchemasVariantIsolation(t *testing.T) { + t.Parallel() + for _, property := range []string{"dotcom", "enterprise", "feature_enabled"} { + t.Run(property, func(t *testing.T) { + t.Parallel() + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{property: {Type: "string"}, "owner": {Type: "string"}}, + } + tool := &mcp.Tool{Name: "same_name", InputSchema: schema, OutputSchema: schema} + AnnotateHeaderParams(tool) + before := &mcp.ListToolsResult{Tools: []*mcp.Tool{tool}, NextCursor: "unchanged"} + want, err := json.Marshal(before) + require.NoError(t, err) + after := listThroughEncoder(t, map[string]listedToolSchemas{ + tool.Name: {source: schema, registered: tool}, + }, before) + got, err := json.Marshal(after) + require.NoError(t, err) + assert.Equal(t, string(want), string(got)) + assert.IsType(t, json.RawMessage{}, after.Tools[0].InputSchema) + assert.IsType(t, json.RawMessage{}, after.Tools[0].OutputSchema) + assert.NotSame(t, before, after) + assert.NotSame(t, tool, after.Tools[0]) + assert.Same(t, schema, tool.OutputSchema) + assert.IsType(t, &jsonschema.Schema{}, tool.InputSchema) + assert.Contains(t, string(got), property) + assert.NotContains(t, string(after.Tools[0].OutputSchema.(json.RawMessage)), "x-mcp-header") + assert.Contains(t, string(after.Tools[0].InputSchema.(json.RawMessage)), "x-mcp-header") + }) + } +} + +type encodingProbe struct { + calls atomic.Int64 + err error +} + +func (p *encodingProbe) MarshalJSON() ([]byte, error) { + p.calls.Add(1) + return []byte(`true`), p.err +} + +func TestEncodedSchemasConcurrentRegistration(t *testing.T) { + t.Parallel() + probe := &encodingProbe{} + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"owner": {Type: "string"}}, + Extra: map[string]any{"x-probe": probe}, + } + original, err := json.Marshal(schema) + require.NoError(t, err) + initialCalls := probe.calls.Load() + const workers = 32 + results := make(chan json.RawMessage, workers) + var wg sync.WaitGroup + for range workers { + wg.Go(func() { + tool := &mcp.Tool{Name: "concurrent", InputSchema: schema} + AnnotateHeaderParams(tool) + before := &mcp.ListToolsResult{Tools: []*mcp.Tool{tool}} + handler := encodedToolSchemasMiddleware(map[string]listedToolSchemas{ + tool.Name: {source: schema, registered: tool}, + })(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return before, nil + }) + for range 10 { + result, err := handler(context.Background(), MCPMethodToolsList, nil) + if !assert.NoError(t, err) { + return + } + after := result.(*mcp.ListToolsResult) + assert.Same(t, tool, before.Tools[0]) + assert.IsType(t, &jsonschema.Schema{}, tool.InputSchema) + data := after.Tools[0].InputSchema.(json.RawMessage) + assert.True(t, json.Valid(data)) + } + result, err := handler(context.Background(), MCPMethodToolsList, nil) + if assert.NoError(t, err) { + results <- result.(*mcp.ListToolsResult).Tools[0].InputSchema.(json.RawMessage) + } + }) + } + wg.Wait() + close(results) + assert.Equal(t, int64(1), probe.calls.Load()-initialCalls, "all registration clones share one encoding") + var first json.RawMessage + for data := range results { + if first == nil { + first = data + } + assert.Equal(t, first, data) + } + after, err := json.Marshal(schema) + require.NoError(t, err) + assert.Equal(t, original, after, "source schema must remain unchanged") +} + +func TestEncodedSchemasRepeatedInventoryRegistration(t *testing.T) { + t.Parallel() + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"owner": {Type: "string"}}, + } + definition := ServerTool{ + Tool: mcp.Tool{Name: "repeated", InputSchema: schema, OutputSchema: schema}, + HandlerFunc: func(any) mcp.ToolHandler { + return func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{}, nil + } + }, + } + original, err := json.Marshal(schema) + require.NoError(t, err) + var first []byte + for range 20 { + inv, err := NewBuilder().SetTools([]ServerTool{definition}).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "repeated"}, nil) + inv.ForMCPRequest(MCPMethodToolsList, "").RegisterTools(context.Background(), server, nil) + st, ct := mcp.NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + require.NoError(t, err) + cs, err := mcp.NewClient(&mcp.Implementation{Name: "test"}, nil).Connect(context.Background(), ct, nil) + require.NoError(t, err) + list, err := cs.ListTools(context.Background(), nil) + _ = cs.Close() + _ = ss.Close() + require.NoError(t, err) + data, err := json.Marshal(list) + require.NoError(t, err) + if first == nil { + first = data + } + assert.Equal(t, first, data) + } + var entries int + encodedSchemas.Range(func(key, _ any) bool { + if key.(encodedSchemaKey).schema == schema { + entries++ + } + return true + }) + assert.Equal(t, 2, entries, "new inventories and annotation clones must reuse the two source-schema keys") + after, err := json.Marshal(schema) + require.NoError(t, err) + assert.Equal(t, original, after) +} + +func TestEncodedSchemasPreserveReplacementsAndErrors(t *testing.T) { + t.Parallel() + source := &jsonschema.Schema{Type: "object"} + registered := &mcp.Tool{Name: "tool", InputSchema: source} + replacement := &mcp.Tool{Name: "tool", InputSchema: &jsonschema.Schema{Type: "object", Description: "replacement"}} + raw := &mcp.Tool{Name: "raw", InputSchema: json.RawMessage(`{"type":"object","properties":{}}`)} + before := &mcp.ListToolsResult{Tools: []*mcp.Tool{replacement, raw}} + after := listThroughEncoder(t, map[string]listedToolSchemas{ + "tool": {source: source, registered: registered}, + "raw": {source: raw.InputSchema, registered: raw}, + }, before) + assert.Same(t, replacement.InputSchema, after.Tools[0].InputSchema) + assert.Equal(t, raw.InputSchema, after.Tools[1].InputSchema) + + broken := &jsonschema.Schema{Type: "object", Extra: map[string]any{ + "x-probe": &encodingProbe{err: errors.New("encoding failed")}, + }} + tool := &mcp.Tool{Name: "broken", InputSchema: broken} + handler := encodedToolSchemasMiddleware(map[string]listedToolSchemas{ + "broken": {source: broken, registered: tool}, + })(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return &mcp.ListToolsResult{Tools: []*mcp.Tool{tool}}, nil + }) + result, err := handler(context.Background(), MCPMethodToolsList, nil) + assert.Nil(t, result) + require.ErrorContains(t, err, `encode input schema for "broken"`) + + wantErr := errors.New("downstream error") + handler = encodedToolSchemasMiddleware(nil)(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return nil, wantErr + }) + _, err = handler(context.Background(), MCPMethodToolsList, nil) + assert.ErrorIs(t, err, wantErr) +} + +func TestEncodedSchemasSharedResultIsImmutable(t *testing.T) { + t.Parallel() + schema := &jsonschema.Schema{Type: "object", Description: "shared"} + tool := &mcp.Tool{Name: "shared", InputSchema: schema} + list := &mcp.ListToolsResult{Tools: []*mcp.Tool{tool}, NextCursor: "cursor"} + handler := encodedToolSchemasMiddleware(map[string]listedToolSchemas{ + tool.Name: {source: schema, registered: tool}, + })(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return list, nil + }) + var wg sync.WaitGroup + for range 32 { + wg.Go(func() { + for range 10 { + result, err := handler(context.Background(), MCPMethodToolsList, nil) + if !assert.NoError(t, err) { + return + } + patched := result.(*mcp.ListToolsResult) + assert.Equal(t, "cursor", patched.NextCursor) + assert.Equal(t, "shared", patched.Tools[0].Name) + assert.Equal(t, json.RawMessage(`{"type":"object","description":"shared"}`), patched.Tools[0].InputSchema) + patched.NextCursor = "caller cursor" + patched.Tools[0].Name = "caller tool" + patched.Tools[0].InputSchema = json.RawMessage(`{}`) + patched.Tools[0] = nil + } + }) + } + wg.Wait() + assert.Same(t, tool, list.Tools[0]) + assert.Same(t, schema, tool.InputSchema) + assert.Equal(t, "shared", tool.Name) + assert.Equal(t, "cursor", list.NextCursor) +} + +func TestEncodedSchemasLeaveSDKValidationIntact(t *testing.T) { + t.Parallel() + type values struct { + Count int `json:"count"` + } + input := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"count": {Type: "integer"}}, + Required: []string{"count"}, + } + output := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"count": {Type: "integer", Minimum: new(float64)}}, + Required: []string{"count"}, + } + for _, optimized := range []bool{false, true} { + t.Run(map[bool]string{false: "baseline", true: "encoded"}[optimized], func(t *testing.T) { + var calls int + server := mcp.NewServer(&mcp.Implementation{Name: "validation"}, &mcp.ServerOptions{SchemaCache: mcp.NewSchemaCache()}) + tool := &mcp.Tool{Name: "validate", InputSchema: input, OutputSchema: output} + mcp.AddTool(server, tool, func(_ context.Context, _ *mcp.CallToolRequest, args values) (*mcp.CallToolResult, values, error) { + calls++ + return nil, args, nil + }) + if optimized { + server.AddReceivingMiddleware(encodedToolSchemasMiddleware(map[string]listedToolSchemas{ + tool.Name: {source: input, registered: tool}, + })) + } + st, ct := mcp.NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + require.NoError(t, err) + defer func() { _ = ss.Close() }() + cs, err := mcp.NewClient(&mcp.Implementation{Name: "test"}, nil).Connect(context.Background(), ct, nil) + require.NoError(t, err) + defer func() { _ = cs.Close() }() + _, err = cs.ListTools(context.Background(), nil) + require.NoError(t, err) + invalid, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Name, Arguments: map[string]any{"count": "wrong"}}) + require.NoError(t, err) + assert.True(t, invalid.IsError) + assert.Zero(t, calls, "input validation rejects before calling the handler") + _, err = cs.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Name, Arguments: values{Count: -1}}) + require.Error(t, err, "output validation must still reject a negative result") + result, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Name, Arguments: values{Count: 1}}) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Equal(t, 2, calls) + assert.Same(t, input, tool.InputSchema) + assert.Same(t, output, tool.OutputSchema) + }) + } +} diff --git a/pkg/inventory/features.go b/pkg/inventory/features.go new file mode 100644 index 0000000000..25665fde82 --- /dev/null +++ b/pkg/inventory/features.go @@ -0,0 +1,269 @@ +package inventory + +import ( + "context" + "fmt" + "os" + "slices" + "sync" +) + +const maxFeatureRuleFlags = 16 + +// FeatureFlag identifies a feature consistently across inventory consumers. +type FeatureFlag string + +// FeatureFlagChecker resolves one feature flag for the current request. Checkers +// must not call ResolveFeature; nested resolution fails the owning check closed. +type FeatureFlagChecker func(ctx context.Context, flag string) (bool, error) + +// FeatureResolver returns the resolved value of a feature flag. +// Implementations absorb resolution errors and fail closed. +type FeatureResolver func(flag FeatureFlag) bool + +// FeaturePredicate determines whether an inventory item is available. Predicates +// must be pure: their result may depend only on calls to the supplied resolver. +type FeaturePredicate func(featureAsBool FeatureResolver) bool + +// FeatureRule declares the feature flags used by an availability predicate. +// The predicate resolves reached flags lazily with normal Go boolean semantics, +// while request state deduplicates repeated checks. +type FeatureRule struct { + features []FeatureFlag + predicate FeaturePredicate +} + +// NewFeatureRule creates an availability rule over the supplied feature flags. +func NewFeatureRule(features []FeatureFlag, predicate FeaturePredicate) FeatureRule { + declared := make([]FeatureFlag, 0, len(features)) + for _, feature := range features { + if feature == "" { + continue + } + if slices.Contains(declared, feature) { + continue + } + declared = append(declared, feature) + } + rule := FeatureRule{ + features: declared, + predicate: predicate, + } + if len(declared) > 0 && predicate == nil { + panic("feature rule declares flags without a predicate") + } + rule.validate() + return rule +} + +func (r FeatureRule) validate() { + if r.predicate == nil { + return + } + if len(r.features) > maxFeatureRuleFlags { + panic(fmt.Sprintf("feature rule declares %d flags; maximum is %d", len(r.features), maxFeatureRuleFlags)) + } + + for assignment := range 1 << len(r.features) { + r.evaluate(func(feature FeatureFlag) bool { + for i, declared := range r.features { + if feature == declared { + return assignment&(1< 0 { + s.AddReceivingMiddleware(typedOutputMiddleware(&typedToolRegistrationSet{byName: registrations})) + } + for _, tool := range tools { + registered := tool.register(s, deps, era, registrations[tool.Tool.Name], middleware...) + sourceSchema := tool.Tool.InputSchema + if sourceSchema == nil { + sourceSchema = registered.InputSchema + } + schemas[registered.Name] = listedToolSchemas{ + source: sourceSchema, + outputSource: tool.Tool.OutputSchema, + registered: registered, + } } + s.AddReceivingMiddleware(encodedToolSchemasMiddleware(schemas)) } // RegisterResourceTemplates registers all available resource templates with the server. // The context is used for feature flag evaluation. // Icons are automatically applied from the toolset metadata if not already set. func (r *Inventory) RegisterResourceTemplates(ctx context.Context, s *mcp.Server, deps any) { - for _, res := range r.AvailableResourceTemplates(ctx) { + ctx = WithFeatureState(ctx, r.featureChecker) + for _, res := range r.availableResourceTemplates(ctx) { // Make a shallow copy to avoid mutating the original templateCopy := res.Template // Apply icons from toolset metadata if not already set @@ -246,7 +307,8 @@ func (r *Inventory) RegisterResourceTemplates(ctx context.Context, s *mcp.Server // The context is used for feature flag evaluation. // Icons are automatically applied from the toolset metadata if not already set. func (r *Inventory) RegisterPrompts(ctx context.Context, s *mcp.Server) { - for _, prompt := range r.AvailablePrompts(ctx) { + ctx = WithFeatureState(ctx, r.featureChecker) + for _, prompt := range r.availablePrompts(ctx) { // Make a shallow copy to avoid mutating the original promptCopy := prompt.Prompt // Apply icons from toolset metadata if not already set @@ -260,6 +322,7 @@ func (r *Inventory) RegisterPrompts(ctx context.Context, s *mcp.Server) { // RegisterAll registers all available tools, resources, and prompts with the server. // The context is used for feature flag evaluation. func (r *Inventory) RegisterAll(ctx context.Context, s *mcp.Server, deps any, middleware ...ToolHandlerMiddleware) { + ctx = r.WithFeatureState(ctx) r.RegisterTools(ctx, s, deps, middleware...) r.RegisterResourceTemplates(ctx, s, deps) r.RegisterPrompts(ctx, s) diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 0a966b289b..ffae37b169 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -5,8 +5,9 @@ import ( "encoding/json" "fmt" "testing" + "time" - ghcontext "github.com/github/github-mcp-server/pkg/context" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) @@ -1044,13 +1045,20 @@ func TestMCPMethodConstants(t *testing.T) { } } -// mockToolWithFlags creates a ServerTool with feature flags for testing +// mockToolWithFlags creates a ServerTool with a functional feature rule for testing. func mockToolWithFlags(name string, toolsetID string, readOnly bool, enableFlag, disableFlag string) ServerTool { tool := mockTool(name, toolsetID, readOnly) - tool.FeatureFlagEnable = enableFlag + features := make([]FeatureFlag, 0, 2) + if enableFlag != "" { + features = append(features, FeatureFlag(enableFlag)) + } if disableFlag != "" { - tool.FeatureFlagDisable = []string{disableFlag} + features = append(features, FeatureFlag(disableFlag)) } + tool.FeatureRule = NewFeatureRule(features, func(featureAsBool FeatureResolver) bool { + return (enableFlag == "" || featureAsBool(FeatureFlag(enableFlag))) && + (disableFlag == "" || !featureAsBool(FeatureFlag(disableFlag))) + }) return tool } @@ -1067,7 +1075,7 @@ func TestFeatureFlagEnable(t *testing.T) { t.Fatalf("Expected 2 tools without feature checker (filtering skipped), got %d", len(available)) } - // With feature checker returning false, FeatureFlagEnable tool is excluded + // With feature checker returning false, the feature-gated tool is excluded. checkerFalse := func(_ context.Context, _ string) (bool, error) { return false, nil } regFalse := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerFalse)) availableFalse := regFalse.AvailableTools(context.Background()) @@ -1095,7 +1103,7 @@ func TestFeatureFlagDisable(t *testing.T) { mockToolWithFlags("disabled_by_flag", "toolset1", true, "", "kill_switch"), } - // Without feature checker, tool with FeatureFlagDisable should be included (flag is false) + // Without feature checker, feature filtering is skipped. reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) available := reg.AvailableTools(context.Background()) if len(available) != 2 { @@ -1164,9 +1172,11 @@ func TestFeatureFlagResources(t *testing.T) { resources := []ServerResourceTemplate{ mockResource("always_available", "toolset1", "uri1"), { - Template: mcp.ResourceTemplate{Name: "needs_flag", URITemplate: "uri2"}, - Toolset: testToolsetMetadata("toolset1"), - FeatureFlagEnable: "my_feature", + Template: mcp.ResourceTemplate{Name: "needs_flag", URITemplate: "uri2"}, + Toolset: testToolsetMetadata("toolset1"), + FeatureRule: NewFeatureRule([]FeatureFlag{"my_feature"}, func(featureAsBool FeatureResolver) bool { + return featureAsBool("my_feature") + }), }, } @@ -1189,9 +1199,11 @@ func TestFeatureFlagPrompts(t *testing.T) { prompts := []ServerPrompt{ mockPrompt("always_available", "toolset1"), { - Prompt: mcp.Prompt{Name: "needs_flag"}, - Toolset: testToolsetMetadata("toolset1"), - FeatureFlagEnable: "my_feature", + Prompt: mcp.Prompt{Name: "needs_flag"}, + Toolset: testToolsetMetadata("toolset1"), + FeatureRule: NewFeatureRule([]FeatureFlag{"my_feature"}, func(featureAsBool FeatureResolver) bool { + return featureAsBool("my_feature") + }), }, } @@ -1210,6 +1222,124 @@ func TestFeatureFlagPrompts(t *testing.T) { } } +func TestRegisterAllSharesFeatureResolution(t *testing.T) { + const feature = FeatureFlag("shared") + rule := NewFeatureRule([]FeatureFlag{feature}, func(featureAsBool FeatureResolver) bool { + return featureAsBool(feature) + }) + tool := mockTool("tool", "toolset1", true) + tool.FeatureRule = rule + resource := mockResource("resource", "toolset1", "test://{id}") + resource.FeatureRule = rule + prompt := mockPrompt("prompt", "toolset1") + prompt.FeatureRule = rule + + calls := 0 + checker := func(context.Context, string) (bool, error) { + calls++ + return calls == 1, nil + } + inv := mustBuild(t, NewBuilder(). + SetTools([]ServerTool{tool}). + SetResources([]ServerResourceTemplate{resource}). + SetPrompts([]ServerPrompt{prompt}). + WithToolsets([]string{"all"}). + WithFeatureChecker(checker)) + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + inv.RegisterAll(context.Background(), server, nil) + clientTransport, serverTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + clientSession, err := client.Connect(context.Background(), clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = clientSession.Close() }) + + tools, err := clientSession.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, tools.Tools, 1) + resources, err := clientSession.ListResourceTemplates(context.Background(), nil) + require.NoError(t, err) + require.Len(t, resources.ResourceTemplates, 1) + prompts, err := clientSession.ListPrompts(context.Background(), nil) + require.NoError(t, err) + require.Len(t, prompts.Prompts, 1) + require.Equal(t, 1, calls) +} + +func TestRequiredFeaturesReflectNarrowedInventory(t *testing.T) { + tools := []ServerTool{ + mockToolWithFlags("tool_x", "toolset1", true, "x", ""), + mockToolWithFlags("tool_y", "toolset1", true, "y", ""), + } + inv := mustBuild(t, NewBuilder(). + SetTools(tools). + WithToolsets([]string{"all"})) + + require.Equal(t, []FeatureFlag{"x", "y"}, inv.RequiredFeatures()) + + narrowed := inv.ForMCPRequest(MCPMethodToolsCall, "tool_x") + require.Equal(t, []FeatureFlag{"x"}, narrowed.RequiredFeatures()) +} + +func TestBuilderCopiesFeatureRuleItems(t *testing.T) { + toolFeatures := []FeatureFlag{"tool"} + toolRule := NewFeatureRule(toolFeatures, func(featureAsBool FeatureResolver) bool { + return featureAsBool("tool") + }) + resourceRule := NewFeatureRule([]FeatureFlag{"resource"}, func(featureAsBool FeatureResolver) bool { + return featureAsBool("resource") + }) + promptRule := NewFeatureRule([]FeatureFlag{"prompt"}, func(featureAsBool FeatureResolver) bool { + return featureAsBool("prompt") + }) + tools := []ServerTool{mockTool("tool", "toolset1", true)} + tools[0].FeatureRule = toolRule + resources := []ServerResourceTemplate{mockResource("resource", "toolset1", "uri")} + resources[0].FeatureRule = resourceRule + prompts := []ServerPrompt{mockPrompt("prompt", "toolset1")} + prompts[0].FeatureRule = promptRule + builder := NewBuilder(). + SetTools(tools). + SetResources(resources). + SetPrompts(prompts). + WithToolsets([]string{"all"}) + + toolFeatures[0] = "changed" + tools[0].FeatureRule = NewFeatureRule([]FeatureFlag{"changed"}, func(featureAsBool FeatureResolver) bool { + return featureAsBool("changed") + }) + resources[0].FeatureRule = tools[0].FeatureRule + prompts[0].FeatureRule = tools[0].FeatureRule + inv := mustBuild(t, builder) + + want := []FeatureFlag{"prompt", "resource", "tool"} + require.Equal(t, want, inv.RequiredFeatures()) +} + +func TestMetadataBehaviorRemainsLive(t *testing.T) { + meta := mcp.Meta{ + "typed_map": map[string]string{"value": "original"}, + "bytes": []byte("original"), + "invalid": make(chan int), + } + tool := mockTool("tool", "toolset1", true) + tool.Tool.Meta = meta + inv, err := NewBuilder().SetTools([]ServerTool{tool}).Build() + require.NoError(t, err) + + meta["ui"] = map[string]any{"resourceUri": "ui://live"} + meta["typed_map"].(map[string]string)["value"] = "changed" + meta["bytes"].([]byte)[0] = 'X' + + require.Empty(t, inv.RequiredFeatures()) + available := inv.AllTools() + require.Equal(t, "changed", available[0].Tool.Meta["typed_map"].(map[string]string)["value"]) + require.Equal(t, byte('X'), available[0].Tool.Meta["bytes"].([]byte)[0]) +} + func TestServerToolHasHandler(t *testing.T) { // Tool with handler toolWithHandler := mockTool("has_handler", "toolset1", true) @@ -1655,10 +1785,9 @@ func TestFilteredToolsMatchesAvailableTools(t *testing.T) { func TestFilteringOrder(t *testing.T) { // Test that filters are applied in the correct order: // 1. Tool.Enabled - // 2. Read-only - // 3. Builder filters (feature-flag filter is at the head of this list - // when WithFeatureChecker is set) - // 4. Toolset/additional tools + // 2. Read-only and builder filters + // 3. Toolset and MCP availability filters + // 4. Feature rule callOrder := []string{} @@ -1685,14 +1814,11 @@ func TestFilteringOrder(t *testing.T) { WithFeatureChecker(checker). WithFilter(filter)) - // Reset call order — Build() may call the checker for MCP Apps metadata. // We're testing the AvailableTools filter order here. callOrder = callOrder[:0] _ = reg.AvailableTools(context.Background()) - // Expected order: Enabled, 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) @@ -1705,10 +1831,152 @@ func TestFilteringOrder(t *testing.T) { } } +func TestReadOnlySkipsWriteToolFeatureChecks(t *testing.T) { + writeTool := mockToolWithFlags("write_tool", "toolset1", false, "write_feature", "") + readTool := mockTool("read_tool", "toolset1", true) + calls := 0 + checker := func(_ context.Context, _ string) (bool, error) { + calls++ + return true, nil + } + + inv := mustBuild(t, NewBuilder(). + SetTools([]ServerTool{writeTool, readTool}). + WithToolsets([]string{"all"}). + WithReadOnly(true). + WithFeatureChecker(checker)) + + available := inv.AvailableTools(context.Background()) + require.Len(t, available, 1) + require.Equal(t, "read_tool", available[0].Tool.Name) + require.Zero(t, calls) +} + +func TestStaticFiltersRunBeforeFeatureChecks(t *testing.T) { + rule := func(feature FeatureFlag) FeatureRule { + return NewFeatureRule([]FeatureFlag{feature}, func(featureAsBool FeatureResolver) bool { + if !featureAsBool(feature) { + return false + } + return featureAsBool(feature) + }) + } + readOnlyExcluded := mockTool("write", "enabled", false) + readOnlyExcluded.FeatureRule = rule("write") + toolsetExcluded := mockTool("wrong_toolset", "disabled", true) + toolsetExcluded.FeatureRule = rule("wrong_toolset") + filterExcluded := mockTool("filtered", "enabled", true) + filterExcluded.FeatureRule = rule("filtered") + enabledExcluded := mockTool("enabled_false", "enabled", true) + enabledExcluded.FeatureRule = rule("enabled_false") + enabledExcluded.Enabled = func(context.Context) (bool, error) { return false, nil } + protocolExcluded := mockTool("protocol", "enabled", true) + protocolExcluded.FeatureRule = rule("protocol") + protocolExcluded.MinimumProtocolVersion = ProtocolVersionMultiRoundTrip + elicitationExcluded := mockTool("elicitation", "enabled", true) + elicitationExcluded.FeatureRule = rule("elicitation") + elicitationExcluded.RequiredElicitationMode = ElicitationModeForm + survivor := mockTool("survivor", "enabled", true) + survivor.FeatureRule = rule("survivor") + resource := mockResource("resource", "disabled", "test://resource") + resource.FeatureRule = rule("resource") + prompt := mockPrompt("prompt", "disabled") + prompt.FeatureRule = rule("prompt") + + calls := make(map[string]int) + checker := func(_ context.Context, feature string) (bool, error) { + time.Sleep(time.Millisecond) + calls[feature]++ + return true, nil + } + inv := mustBuild(t, NewBuilder(). + SetTools([]ServerTool{ + readOnlyExcluded, + toolsetExcluded, + filterExcluded, + enabledExcluded, + protocolExcluded, + elicitationExcluded, + survivor, + }). + SetResources([]ServerResourceTemplate{resource}). + SetPrompts([]ServerPrompt{prompt}). + WithToolsets([]string{"enabled"}). + WithReadOnly(true). + WithFeatureChecker(checker). + WithFilter(func(_ context.Context, tool *ServerTool) (bool, error) { + return tool.Tool.Name != "filtered", nil + })) + + ctx := ghcontext.WithMCPMethodInfo(context.Background(), &ghcontext.MCPMethodInfo{ + Method: MCPMethodToolsList, + ProtocolVersion: "2025-11-25", + ClientCapabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}}, + }) + require.Len(t, inv.AvailableTools(ctx), 1) + require.Empty(t, inv.AvailableResourceTemplates(ctx)) + require.Empty(t, inv.AvailablePrompts(ctx)) + require.Equal(t, map[string]int{"survivor": 1}, calls) +} + +func TestMCPAvailabilityRunsBeforeFeatureChecks(t *testing.T) { + rule := NewFeatureRule([]FeatureFlag{"feature"}, func(featureAsBool FeatureResolver) bool { + return featureAsBool("feature") + }) + protocolTool := mockTool("protocol", "toolset1", true) + protocolTool.FeatureRule = rule + protocolTool.MinimumProtocolVersion = ProtocolVersionMultiRoundTrip + elicitationTool := mockTool("elicitation", "toolset1", true) + elicitationTool.FeatureRule = rule + elicitationTool.RequiredElicitationMode = ElicitationModeForm + + for _, method := range []string{MCPMethodToolsList, MCPMethodToolsCall} { + t.Run(method, func(t *testing.T) { + calls := 0 + checker := func(_ context.Context, _ string) (bool, error) { + time.Sleep(time.Millisecond) + calls++ + return true, nil + } + inv := mustBuild(t, NewBuilder(). + SetTools([]ServerTool{protocolTool, elicitationTool}). + WithToolsets([]string{"all"}). + WithFeatureChecker(checker)) + ctx := ghcontext.WithMCPMethodInfo(context.Background(), &ghcontext.MCPMethodInfo{ + Method: method, + ProtocolVersion: "2025-11-25", + ClientCapabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}}, + }) + + if method == MCPMethodToolsList { + require.Empty(t, inv.AvailableTools(ctx)) + } else { + require.Len(t, inv.AvailableTools(ctx), 2) + } + require.Zero(t, calls) + + ctx = ghcontext.WithMCPMethodInfo(context.Background(), &ghcontext.MCPMethodInfo{ + Method: method, + ProtocolVersion: ProtocolVersionMultiRoundTrip, + ClientCapabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{Form: &mcp.FormElicitationCapabilities{}}, + }, + }) + require.Len(t, inv.AvailableTools(ctx), 2) + require.Equal(t, 1, calls) + + calls = 0 + ctx = ghcontext.WithMCPMethodInfo(context.Background(), &ghcontext.MCPMethodInfo{Method: method}) + require.Len(t, inv.AvailableTools(ctx), 2) + require.Equal(t, 1, calls) + }) + } +} + func TestForMCPRequest_ToolsCall_FeatureFlaggedVariants(t *testing.T) { // Simulate the get_job_logs scenario: two tools with the same name but different feature flags - // - "get_job_logs" with FeatureFlagDisable (available when flag is OFF) - // - "get_job_logs" with FeatureFlagEnable (available when flag is ON) + // - one "get_job_logs" variant available when the flag is off + // - one "get_job_logs" variant available when the flag is on tools := []ServerTool{ mockToolWithFlags("get_job_logs", "actions", true, "", "consolidated_flag"), // disabled when flag is ON mockToolWithFlags("get_job_logs", "actions", true, "consolidated_flag", ""), // enabled when flag is ON @@ -1726,9 +1994,8 @@ func TestForMCPRequest_ToolsCall_FeatureFlaggedVariants(t *testing.T) { if len(availableOff) != 1 { t.Fatalf("Flag OFF: Expected 1 tool, got %d", len(availableOff)) } - 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) + if !availableOff[0].FeatureRule.Enabled(func(FeatureFlag) bool { return false }) { + t.Error("Flag OFF: expected the flag-off feature rule") } // Test 2: Flag is ON - second tool variant should be available @@ -1744,16 +2011,15 @@ func TestForMCPRequest_ToolsCall_FeatureFlaggedVariants(t *testing.T) { 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=%v", - availableOn[0].FeatureFlagEnable, availableOn[0].FeatureFlagDisable) + if !availableOn[0].FeatureRule.Enabled(func(FeatureFlag) bool { return true }) { + t.Error("Flag ON: expected the flag-on feature rule") } } // 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) +// - Old tool "old_tool" is available when the flag is off +// - New tool "new_tool" is available when the flag is on // - Deprecated alias maps "old_tool" -> "new_tool" // - User specifies --tools=old_tool // Expected behavior: @@ -1822,15 +2088,14 @@ func mockToolWithMeta(name string, toolsetID string, meta map[string]any) Server ) } -func TestWithMCPApps_DisabledStripsUIMetadata(t *testing.T) { +func TestWithMCPApps_UnsupportedClientStripsUIMetadata(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) + registered := captureRegisteredTools(ghcontext.WithUISupport(context.Background(), false), t, reg) require.Len(t, registered, 1) if registered[0].Meta["ui"] != nil { @@ -1841,27 +2106,55 @@ func TestWithMCPApps_DisabledStripsUIMetadata(t *testing.T) { } } -func TestWithMCPApps_EnabledPreservesUIMetadata(t *testing.T) { +func TestWithMCPApps_UnsupportedClientOmitsAppOnlyTools(t *testing.T) { + appOnly := mockToolWithMeta("app_only", "toolset1", map[string]any{ + "ui": map[string]any{"visibility": []string{"app"}}, + }) + appOnlyAny := mockToolWithMeta("app_only_any", "toolset1", map[string]any{ + "ui": map[string]any{"visibility": []any{"app"}}, + }) + modelAndApp := mockToolWithMeta("model_and_app", "toolset1", map[string]any{ + "ui": map[string]any{"resourceUri": "ui://x", "visibility": []string{"model", "app"}}, + }) + noVisibility := mockToolWithMeta("no_visibility", "toolset1", map[string]any{ + "ui": map[string]any{"resourceUri": "ui://y"}, + }) + reg := mustBuild(t, NewBuilder(). + SetTools([]ServerTool{appOnly, appOnlyAny, modelAndApp, noVisibility}). + WithToolsets([]string{"all"})) + + stripped := reg.ToolsForRegistration(ghcontext.WithUISupport(context.Background(), false)) + names := make([]string, 0, len(stripped)) + for _, tool := range stripped { + names = append(names, tool.Tool.Name) + require.Nil(t, tool.Tool.Meta["ui"], "ui meta should be stripped from %s", tool.Tool.Name) + } + require.ElementsMatch(t, []string{"model_and_app", "no_visibility"}, names) + + for _, ctx := range []context.Context{ + context.Background(), + ghcontext.WithUISupport(context.Background(), true), + } { + require.Len(t, reg.ToolsForRegistration(ctx), 4, "app-only tools should be kept when UI is supported or unknown") + } +} + +func TestWithMCPApps_PreservesUIMetadataByDefault(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()) + WithToolsets([]string{"all"})) + available := reg.ToolsForRegistration(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") + t.Errorf("Expected 'ui' meta to be preserved") } // Other metadata should also be preserved if available[0].Tool.Meta["description"] != "kept" { @@ -1876,7 +2169,6 @@ func TestWithMCPApps_ToolsWithoutUIMetaUnaffected(t *testing.T) { }) 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"})) @@ -1917,7 +2209,7 @@ func TestWithMCPApps_UIOnlyMetaBecomesNil(t *testing.T) { reg := mustBuild(t, NewBuilder(). SetTools([]ServerTool{toolUIOnly}). WithToolsets([]string{"all"})) - registered := captureRegisteredTools(context.Background(), t, reg) + registered := captureRegisteredTools(ghcontext.WithUISupport(context.Background(), false), t, reg) require.Len(t, registered, 1) if registered[0].Meta != nil { @@ -2045,8 +2337,8 @@ func TestWithMCPApps_DoesNotMutateOriginalTools(t *testing.T) { 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"})) + reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) + _ = reg.ToolsForRegistration(ghcontext.WithUISupport(context.Background(), false)) // Original tool should be unchanged require.Equal(t, "data", tools[0].Tool.Meta["ui"], "original tool should not be mutated") @@ -2218,52 +2510,36 @@ func captureRegisteredTools(ctx context.Context, t *testing.T, reg *Inventory) [ } // 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. +// strip only 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", + name: "capability unknown -> keep", setupCtx: context.Background, - ffOn: true, want: false, }, { - name: "FF on, capability present -> keep", + name: "capability present -> keep", setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), true) }, - ffOn: true, want: false, }, { - name: "FF on, capability explicitly absent -> strip", + name: "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) + got := shouldStripMCPAppsMetadata(tc.setupCtx()) require.Equal(t, tc.want, got) }) } diff --git a/pkg/inventory/resources.go b/pkg/inventory/resources.go index 2dd07ae0fe..62a846120f 100644 --- a/pkg/inventory/resources.go +++ b/pkg/inventory/resources.go @@ -16,12 +16,8 @@ type ServerResourceTemplate struct { HandlerFunc ResourceHandlerFunc // Toolset identifies which toolset this resource belongs to Toolset ToolsetMetadata - // 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 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 + // FeatureRule controls whether this resource is available. + FeatureRule FeatureRule } // 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 110a803fe2..cb59fb8aec 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -4,10 +4,13 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "maps" + "reflect" + "slices" - "github.com/github/github-mcp-server/pkg/octicons" + "github.com/github/github-mcp-server/v2/pkg/octicons" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -44,12 +47,55 @@ type ScopeAccess struct { // Dynamic reports whether Challenge depends on tool arguments. Dynamic // policies must declare their exhaustive upper bound in Scopes. Dynamic bool + + // ArgumentNormalizer canonicalizes raw arguments before evaluating a + // dynamic scope challenge. Inventory populates this from the tool's + // InputNormalizer when building the HTTP scope map. + ArgumentNormalizer InputNormalizer +} + +func (r *typedToolRegistration) runtimeTool(era ProtocolEra) *mcp.Tool { + if era == ProtocolEraLegacy { + return r.legacyRuntimeTool + } + return r.modernRuntimeTool } // ToolHandlerMiddleware wraps an MCP tool handler. Middleware is applied from // right to left, so the first middleware passed to RegisterFunc executes first. type ToolHandlerMiddleware func(next mcp.ToolHandler) mcp.ToolHandler +// ToolHandlerMiddlewareProvider creates tool-specific middleware using the +// dependencies supplied when the tool is registered. +type ToolHandlerMiddlewareProvider func(deps any) ToolHandlerMiddleware + +// InputNormalizer transforms raw tool arguments before the SDK validates and +// decodes them. Use it only for compatibility normalization; the registered +// input schema remains the contract clients see and the SDK validates. +type InputNormalizer func(json.RawMessage) (json.RawMessage, error) + +// ToolCallPreflight inspects raw call arguments before SDK schema validation. +// Return nil, nil to continue; a result or error short-circuits the call. +// Returning a non-nil context passes request-scoped preparation to the handler. +type ToolCallPreflight func(context.Context, *mcp.CallToolRequest) (context.Context, *mcp.CallToolResult, error) + +// TypedSchemaOptions customizes schemas inferred for a typed tool. +// +// Input and Output are passed to jsonschema.For when the corresponding schema +// is not provided on the tool. ValidationInputSchema, when set, is used by the +// SDK to validate calls while the tool's declared InputSchema remains visible +// to clients. InputEnums and OutputEnums are applied to inferred schemas at +// registration time. +type TypedSchemaOptions struct { + Input *jsonschema.ForOptions + Output *jsonschema.ForOptions + ValidationInputSchema *jsonschema.Schema + InputEnums []SchemaEnum + OutputEnums []SchemaEnum + Preflight ToolCallPreflight + PreserveHandlerContent bool +} + // ToolsetID is a unique identifier for a toolset. // Using a distinct type provides compile-time type safety. type ToolsetID string @@ -94,17 +140,13 @@ type ServerTool struct { // and handlers are only created when needed. HandlerFunc HandlerFunc - // FeatureFlagEnable specifies a feature flag that must be enabled for this tool - // to be available. If set and the flag is not enabled, the tool is omitted. - FeatureFlagEnable 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 + // FeatureRule declares and evaluates the feature flags that control whether + // this tool is available. Its zero value leaves the tool available. + FeatureRule FeatureRule // 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 - // (subject to FeatureFlagEnable/FeatureFlagDisable checks). + // (subject to feature flag checks). // The context carries request-scoped information for the consumer to use. // Returns (enabled, error). On error, the tool should be treated as disabled. Enabled func(ctx context.Context) (bool, error) @@ -119,6 +161,12 @@ type ServerTool struct { // ScopeAccess controls fixed-token visibility and per-call OAuth challenges. ScopeAccess ScopeAccess + + registerTyped func(*mcp.Server, *typedToolRegistration, ProtocolEra, ...ToolHandlerMiddleware) + prepareTyped func(*mcp.Tool) *typedToolRegistration + handlerMiddlewareProviders []ToolHandlerMiddlewareProvider + inputNormalizer InputNormalizer + schemaOptions TypedSchemaOptions } // IsReadOnly returns true if this tool is marked as read-only via annotations. @@ -131,13 +179,35 @@ func (st *ServerTool) HasHandler() bool { return st.HandlerFunc != nil } +// GetInputNormalizer returns the compatibility normalizer configured for this +// tool, if any. +func (st *ServerTool) GetInputNormalizer() InputNormalizer { + return st.inputNormalizer +} + // Handler returns a tool handler by calling HandlerFunc with the given dependencies. // Panics if HandlerFunc is nil - all tools should have handlers. func (st *ServerTool) Handler(deps any) mcp.ToolHandler { if st.HandlerFunc == nil { panic("HandlerFunc is nil for tool: " + st.Tool.Name) } - return st.HandlerFunc(deps) + return applyToolHandlerMiddleware(st.HandlerFunc(deps), st.handlerMiddlewares(deps)...) +} + +// AddHandlerMiddleware adds dependency-aware middleware used on both typed and +// raw registration paths. +func (st *ServerTool) AddHandlerMiddleware(provider ToolHandlerMiddlewareProvider) { + if provider != nil { + st.handlerMiddlewareProviders = append(st.handlerMiddlewareProviders, provider) + } +} + +func (st *ServerTool) handlerMiddlewares(deps any) []ToolHandlerMiddleware { + middleware := make([]ToolHandlerMiddleware, 0, len(st.handlerMiddlewareProviders)) + for _, provider := range st.handlerMiddlewareProviders { + middleware = append(middleware, provider(deps)) + } + return middleware } // RegisterFunc registers the tool with the server using the provided dependencies. @@ -145,22 +215,62 @@ func (st *ServerTool) Handler(deps any) mcp.ToolHandler { // A shallow copy of the tool is made to avoid mutating the original ServerTool. // Panics if the tool has no handler - all tools should have handlers. func (st *ServerTool) RegisterFunc(s *mcp.Server, deps any, middleware ...ToolHandlerMiddleware) { - handler := st.Handler(deps) // This will panic if HandlerFunc is nil - for i := len(middleware) - 1; i >= 0; i-- { - handler = middleware[i](handler) + st.RegisterFuncForProtocolEra(s, deps, ProtocolEraDynamic, middleware...) +} + +// RegisterFuncForProtocolEra registers a preselected legacy or modern tool +// variant. Use this when the protocol era is known before server construction. +func (st *ServerTool) RegisterFuncForProtocolEra(s *mcp.Server, deps any, era ProtocolEra, middleware ...ToolHandlerMiddleware) { + st.registerFunc(s, deps, true, era, nil, middleware...) +} + +func (st *ServerTool) register(s *mcp.Server, deps any, era ProtocolEra, registration *typedToolRegistration, middleware ...ToolHandlerMiddleware) *mcp.Tool { + return st.registerFunc(s, deps, false, era, registration, middleware...) +} + +func (st *ServerTool) registerFunc(s *mcp.Server, deps any, addTypedOutputMiddleware bool, era ProtocolEra, registration *typedToolRegistration, middleware ...ToolHandlerMiddleware) *mcp.Tool { + if st.HandlerFunc == nil { + panic("HandlerFunc is nil for tool: " + st.Tool.Name) } - handler = st.wrapAvailabilityCheck(handler) // Make a shallow copy of the tool to avoid mutating the original toolCopy := st.Tool - // Apply icons from toolset metadata if tool doesn't have icons set - if len(toolCopy.Icons) == 0 { - toolCopy.Icons = st.Toolset.Icons() + if registration == nil { + // Apply icons from toolset metadata if tool doesn't have icons set + if len(toolCopy.Icons) == 0 { + toolCopy.Icons = st.Toolset.Icons() + } + // Project owner/repo routing params to standard MCP-Param-* headers (SEP-2243). + AnnotateHeaderParams(&toolCopy) + registration = st.typedRegistration(&toolCopy) } - // Project owner/repo routing params to standard MCP-Param-* headers (SEP-2243) - // so a remote proxy can route requests without re-parsing the JSON-RPC body. - // No-op for tools without these params. - AnnotateHeaderParams(&toolCopy) - s.AddTool(&toolCopy, handler) + registration.fixedEra = era + if addTypedOutputMiddleware { + s.AddReceivingMiddleware(typedOutputMiddleware(&typedToolRegistrationSet{ + byName: map[string]*typedToolRegistration{st.Tool.Name: registration}, + })) + } + allMiddleware := make([]ToolHandlerMiddleware, 0, len(middleware)+len(st.handlerMiddlewareProviders)+1) + allMiddleware = append(allMiddleware, func(next mcp.ToolHandler) mcp.ToolHandler { + return st.wrapAvailabilityCheck(next) + }) + allMiddleware = append(allMiddleware, middleware...) + allMiddleware = append(allMiddleware, st.handlerMiddlewares(deps)...) + if st.registerTyped != nil { + registration.handlerMiddleware = allMiddleware + st.registerTyped(s, registration, era) + return registration.runtimeTool(era) + } + + handler := applyToolHandlerMiddleware(st.HandlerFunc(deps), allMiddleware...) + s.AddTool(registration.runtimeTool(era), handler) + return registration.runtimeTool(era) +} + +func applyToolHandlerMiddleware(handler mcp.ToolHandler, middleware ...ToolHandlerMiddleware) mcp.ToolHandler { + for i := range slices.Backward(middleware) { + handler = middleware[i](handler) + } + return handler } // HeaderParams maps owner/repo input properties to the MCP-Param-* headers a @@ -181,6 +291,26 @@ func AnnotateHeaderParams(tool *mcp.Tool) { if !ok || schema == nil { return } + if _, owned := ownedSchemaPointers.Load(schema); owned { + entryValue, found := annotatedSchemaCache.Load(schema) + if !found { + entryValue, _ = annotatedSchemaCache.LoadOrStore(schema, &inferredSchemaEntry{}) + } + entry := entryValue.(*inferredSchemaEntry) + entry.once.Do(func() { + annotatedTool := mcp.Tool{InputSchema: schema} + annotateHeaderParams(&annotatedTool) + entry.schema = annotatedTool.InputSchema.(*jsonschema.Schema) + ownedSchemaPointers.Store(entry.schema, struct{}{}) + }) + tool.InputSchema = entry.schema + return + } + annotateHeaderParams(tool) +} + +func annotateHeaderParams(tool *mcp.Tool) { + schema := tool.InputSchema.(*jsonschema.Schema) // Collect params that actually need an annotation, so a tool without // owner/repo (or already annotated) is left untouched and unCloned. @@ -217,19 +347,66 @@ func AnnotateHeaderParams(tool *mcp.Tool) { // 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. // +// When Out is concrete, registration uses mcp.AddTool so the SDK infers missing +// schemas and validates typed input and output. Out=any retains the raw handler +// registration path. Optional InputNormalizer callbacks can canonicalize raw +// JSON before SDK validation without weakening the advertised schema. Direct +// Handler calls apply the same normalization before decoding. +// // The handler function is stored directly without wrapping in a deps closure. // Dependencies should be injected into context before calling tool handlers. -func NewServerToolWithContextHandler[In any, Out any](tool mcp.Tool, toolset ToolsetMetadata, handler mcp.ToolHandlerFor[In, Out]) ServerTool { - return ServerTool{ - Tool: tool, - Toolset: toolset, +func NewServerToolWithContextHandler[In any, Out any](tool mcp.Tool, toolset ToolsetMetadata, handler mcp.ToolHandlerFor[In, Out], inputNormalizers ...InputNormalizer) ServerTool { + return NewServerToolWithContextHandlerAndSchemaOptions( + tool, + toolset, + handler, + TypedSchemaOptions{}, + inputNormalizers..., + ) +} + +// NewServerToolWithContextHandlerAndSchemaOptions is like +// NewServerToolWithContextHandler, with additional schema inference options. +// Inferred input and output schemas are cached per ServerTool. +func NewServerToolWithContextHandlerAndSchemaOptions[In any, Out any]( + tool mcp.Tool, + toolset ToolsetMetadata, + handler mcp.ToolHandlerFor[In, Out], + schemaOptions TypedSchemaOptions, + inputNormalizers ...InputNormalizer, +) ServerTool { + schemaOptions = cloneTypedSchemaOptions(schemaOptions) + cacheExplicitToolSchemas(&tool) + inputNormalizer := combineInputNormalizers(inputNormalizers) + serverTool := ServerTool{ + Tool: tool, + Toolset: toolset, + inputNormalizer: inputNormalizer, + schemaOptions: schemaOptions, // HandlerFunc ignores deps - deps are retrieved from context at call time HandlerFunc: func(_ any) mcp.ToolHandler { return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if schemaOptions.Preflight != nil && !preflightAlreadyRun(ctx, req.Params.Name) { + preflightContext, result, err := schemaOptions.Preflight(ctx, req) + if result != nil || err != nil { + return result, err + } + if preflightContext != nil { + ctx = preflightContext + } + ctx = markPreflightRun(ctx, req.Params.Name) + } rawArguments := req.Params.Arguments if len(rawArguments) == 0 { rawArguments = json.RawMessage(`{}`) } + if inputNormalizer != nil && !inputAlreadyNormalized(ctx, req.Params.Name) { + normalized, err := inputNormalizer(rawArguments) + if err != nil { + return invalidArgumentsResult(fmt.Errorf("normalize tool arguments: %w", err)), nil + } + rawArguments = normalized + } if bytes.Equal(bytes.TrimSpace(rawArguments), []byte("null")) { return invalidArgumentsResult(fmt.Errorf("arguments must be a JSON object")), nil @@ -244,9 +421,177 @@ func NewServerToolWithContextHandler[In any, Out any](tool mcp.Tool, toolset Too } }, } + if reflect.TypeFor[Out]() != reflect.TypeFor[any]() { + serverTool.prepareTyped = func(tool *mcp.Tool) *typedToolRegistration { + modernTool := *tool + cacheExplicitToolSchemas(&modernTool) + if modernTool.InputSchema == nil && reflect.TypeFor[In]() != reflect.TypeFor[any]() { + inferred, err := CachedInputSchemaFor[In](schemaOptions.Input, schemaOptions.InputEnums...) + if err != nil { + panic(fmt.Sprintf("failed to generate input schema for tool %q: %v", tool.Name, err)) + } + modernTool.InputSchema = inferred + } else if modernTool.InputSchema == nil { + inferred, err := cachedObjectInputSchema() + if err != nil { + panic(fmt.Sprintf("failed to prepare default input schema for tool %q: %v", tool.Name, err)) + } + modernTool.InputSchema = inferred + } + if modernTool.OutputSchema == nil { + inferred, err := CachedSchemaFor[Out](schemaOptions.Output, schemaOptions.OutputEnums...) + if err != nil { + panic(fmt.Sprintf("failed to generate output schema for tool %q: %v", tool.Name, err)) + } + modernTool.OutputSchema = inferred + } + legacyTool := modernTool + legacyTool.OutputSchema = nil + modernRuntimeTool := modernTool + legacyRuntimeTool := legacyTool + if schemaOptions.ValidationInputSchema != nil { + modernRuntimeTool.InputSchema = schemaOptions.ValidationInputSchema + legacyRuntimeTool.InputSchema = schemaOptions.ValidationInputSchema + } + return &typedToolRegistration{ + name: tool.Name, + modernTool: &modernTool, + legacyTool: &legacyTool, + modernRuntimeTool: &modernRuntimeTool, + legacyRuntimeTool: &legacyRuntimeTool, + hasTypedOutput: true, + inputNormalizer: inputNormalizer, + preflight: schemaOptions.Preflight, + preserveContent: schemaOptions.PreserveHandlerContent, + } + } + serverTool.registerTyped = func(server *mcp.Server, registration *typedToolRegistration, era ProtocolEra, _ ...ToolHandlerMiddleware) { + tool := *registration.runtimeTool(era) + inputSchema, err := cachedResolvedInputSchema(tool.InputSchema) + if err != nil { + panic(fmt.Sprintf("failed to resolve input schema for tool %q: %v", tool.Name, err)) + } + // Input validation is deferred until guards have allowed the call. + // Retain the advertised header bindings for checks at the original + // HTTP boundary, even when the body validation schema is overridden. + tool.InputSchema, err = cachedHeaderInputSchema(registration.modernTool.InputSchema) + if err != nil { + panic(fmt.Sprintf("failed to prepare input schema for tool %q: %v", tool.Name, err)) + } + mcp.AddTool[any, any](server, &tool, wrapTypedHandler(handler, inputSchema)) + } + } + return serverTool +} + +func (st *ServerTool) typedRegistration(tool *mcp.Tool) *typedToolRegistration { + if st.prepareTyped != nil { + return st.prepareTyped(tool) + } + modernTool := *tool + cacheExplicitToolSchemas(&modernTool) + legacyTool := modernTool + return &typedToolRegistration{ + name: tool.Name, + modernTool: &modernTool, + legacyTool: &legacyTool, + modernRuntimeTool: &modernTool, + legacyRuntimeTool: &legacyTool, + inputNormalizer: st.inputNormalizer, + } +} + +func cacheExplicitToolSchemas(tool *mcp.Tool) { + if schema, ok := tool.InputSchema.(*jsonschema.Schema); ok { + cached, err := CachedSchema(schema) + if err != nil { + panic(fmt.Sprintf("failed to cache input schema for tool %q: %v", tool.Name, err)) + } + tool.InputSchema = cached + } + if schema, ok := tool.OutputSchema.(*jsonschema.Schema); ok { + cached, err := CachedSchema(schema) + if err != nil { + panic(fmt.Sprintf("failed to cache output schema for tool %q: %v", tool.Name, err)) + } + tool.OutputSchema = cached + } +} + +func cloneTypedSchemaOptions(options TypedSchemaOptions) TypedSchemaOptions { + options.Input = cloneJSONSchemaForOptions(options.Input) + options.Output = cloneJSONSchemaForOptions(options.Output) + if options.ValidationInputSchema != nil { + cached, err := CachedSchema(options.ValidationInputSchema) + if err != nil { + panic(fmt.Sprintf("failed to cache validation input schema: %v", err)) + } + options.ValidationInputSchema = cached + } + options.InputEnums = slices.Clone(options.InputEnums) + for i := range options.InputEnums { + options.InputEnums[i].Values = slices.Clone(options.InputEnums[i].Values) + } + options.OutputEnums = slices.Clone(options.OutputEnums) + for i := range options.OutputEnums { + options.OutputEnums[i].Values = slices.Clone(options.OutputEnums[i].Values) + } + return options +} + +func cloneJSONSchemaForOptions(options *jsonschema.ForOptions) *jsonschema.ForOptions { + if options == nil { + return nil + } + optionsCopy := *options + if options.TypeSchemas != nil { + optionsCopy.TypeSchemas = make(map[reflect.Type]*jsonschema.Schema, len(options.TypeSchemas)) + for typ, schema := range options.TypeSchemas { + if schema != nil { + optionsCopy.TypeSchemas[typ] = CloneSchema(schema) + } else { + optionsCopy.TypeSchemas[typ] = nil + } + } + } + return &optionsCopy +} + +func combineInputNormalizers(normalizers []InputNormalizer) InputNormalizer { + var active []InputNormalizer + for _, normalizer := range normalizers { + if normalizer != nil { + active = append(active, normalizer) + } + } + switch len(active) { + case 0: + return nil + case 1: + return active[0] + default: + return func(arguments json.RawMessage) (json.RawMessage, error) { + for _, normalizer := range active { + normalized, err := normalizer(arguments) + if err != nil { + return nil, err + } + arguments = normalized + } + return arguments, nil + } + } } func invalidArgumentsResult(err error) *mcp.CallToolResult { + if inputError, ok := errors.AsType[*ToolInputError](err); ok { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: inputError.Message}, + }, + IsError: true, + } + } return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: fmt.Sprintf("invalid arguments: %s", err)}, @@ -263,6 +608,7 @@ func invalidArgumentsResult(err error) *mcp.CallToolResult { // The handler function is stored directly without wrapping in a deps closure. // Dependencies should be injected into context before calling tool handlers. func NewServerTool(tool mcp.Tool, toolset ToolsetMetadata, handler mcp.ToolHandler) ServerTool { + cacheExplicitToolSchemas(&tool) return ServerTool{ Tool: tool, Toolset: toolset, diff --git a/pkg/inventory/tool_availability.go b/pkg/inventory/tool_availability.go index 937726a8c7..3951dcaa6f 100644 --- a/pkg/inventory/tool_availability.go +++ b/pkg/inventory/tool_availability.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -26,6 +27,14 @@ type toolAvailability struct { requiredElicitationMode ElicitationMode } +type toolFeatureDecision uint8 + +const ( + evaluateToolFeatureRule toolFeatureDecision = iota + excludeToolBeforeFeatureRule + includeToolWithoutFeatureRule +) + func (st *ServerTool) availability() toolAvailability { return toolAvailability{ minimumProtocolVersion: st.MinimumProtocolVersion, @@ -37,6 +46,40 @@ func (a toolAvailability) unrestricted() bool { return a.minimumProtocolVersion == "" && a.requiredElicitationMode == "" } +func featureDecisionForToolAvailability(ctx context.Context, availability toolAvailability) toolFeatureDecision { + if availability.unrestricted() { + return evaluateToolFeatureRule + } + info, ok := ghcontext.MCPMethod(ctx) + if !ok || info == nil || (info.Method != MCPMethodToolsList && info.Method != MCPMethodToolsCall) { + return evaluateToolFeatureRule + } + + available, known := knownToolAvailability(info.ProtocolVersion, info.ClientCapabilities, availability) + if !known || available { + return evaluateToolFeatureRule + } + if info.Method == MCPMethodToolsCall { + return includeToolWithoutFeatureRule + } + return excludeToolBeforeFeatureRule +} + +func knownToolAvailability(protocolVersion string, capabilities *mcp.ClientCapabilities, availability toolAvailability) (bool, bool) { + protocolKnown := availability.minimumProtocolVersion == "" || protocolVersion != "" + if protocolKnown && !protocolVersionAllowed(protocolVersion, availability.minimumProtocolVersion) { + return false, true + } + capabilitiesKnown := availability.requiredElicitationMode == "" || capabilities != nil + if capabilitiesKnown && !elicitationModeSupported(capabilities, availability.requiredElicitationMode) { + return false, true + } + if !protocolKnown || !capabilitiesKnown { + return false, false + } + return true, true +} + func addToolAvailabilityMiddleware(server *mcp.Server, tools []ServerTool) { availabilityByName := make(map[string]toolAvailability) for _, tool := range tools { diff --git a/pkg/inventory/tool_availability_test.go b/pkg/inventory/tool_availability_test.go index 1f76e3cb26..a59905b2e4 100644 --- a/pkg/inventory/tool_availability_test.go +++ b/pkg/inventory/tool_availability_test.go @@ -5,7 +5,9 @@ import ( "encoding/json" "errors" "testing" + "time" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" @@ -140,6 +142,146 @@ func TestToolAvailability(t *testing.T) { } } +func TestToolCallAvailabilitySkipsFeatureChecksOnlyWhenUnavailable(t *testing.T) { + tests := []struct { + name string + minimumProtocolVersion string + requiredElicitationMode ElicitationMode + methodInfo *ghcontext.MCPMethodInfo + clientCapabilities *mcp.ClientCapabilities + legacyProtocol bool + wantCheckerCalls int + wantHandlerCalls int + wantError string + }{ + { + name: "protocol unavailable", + minimumProtocolVersion: ProtocolVersionMultiRoundTrip, + methodInfo: &ghcontext.MCPMethodInfo{ + Method: MCPMethodToolsCall, + ProtocolVersion: "2025-11-25", + ClientCapabilities: &mcp.ClientCapabilities{}, + }, + legacyProtocol: true, + wantError: `Tool "restricted" requires MCP protocol version 2026-07-28 or later.`, + wantCheckerCalls: 0, + }, + { + name: "elicitation unavailable", + requiredElicitationMode: ElicitationModeForm, + methodInfo: &ghcontext.MCPMethodInfo{ + Method: MCPMethodToolsCall, + ProtocolVersion: ProtocolVersionMultiRoundTrip, + ClientCapabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}, + }, + }, + clientCapabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}, + }, + wantError: `Tool "restricted" requires client support for form elicitation.`, + wantCheckerCalls: 0, + }, + { + name: "eligible", + minimumProtocolVersion: ProtocolVersionMultiRoundTrip, + requiredElicitationMode: ElicitationModeForm, + methodInfo: &ghcontext.MCPMethodInfo{ + Method: MCPMethodToolsCall, + ProtocolVersion: ProtocolVersionMultiRoundTrip, + ClientCapabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{Form: &mcp.FormElicitationCapabilities{}}, + }, + }, + clientCapabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{Form: &mcp.FormElicitationCapabilities{}}, + }, + wantCheckerCalls: 1, + wantHandlerCalls: 1, + }, + { + name: "unknown metadata defers", + minimumProtocolVersion: ProtocolVersionMultiRoundTrip, + requiredElicitationMode: ElicitationModeForm, + methodInfo: &ghcontext.MCPMethodInfo{Method: MCPMethodToolsCall}, + clientCapabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{Form: &mcp.FormElicitationCapabilities{}}, + }, + wantCheckerCalls: 1, + wantHandlerCalls: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkerCalls := 0 + handlerCalls := 0 + tool := availabilityTestTool( + "restricted", + tt.minimumProtocolVersion, + tt.requiredElicitationMode, + func() { handlerCalls++ }, + ) + if tt.wantError != "" { + tool.Tool.Meta = map[string]any{"ui": map[string]any{"resourceUri": "ui://restricted"}} + } + tool.FeatureRule = NewFeatureRule([]FeatureFlag{"feature"}, func(featureAsBool FeatureResolver) bool { + return featureAsBool("feature") + }) + inv, err := NewBuilder(). + SetTools([]ServerTool{tool}). + WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { + time.Sleep(time.Millisecond) + checkerCalls++ + return true, nil + }). + Build() + require.NoError(t, err) + + ctx := ghcontext.WithMCPMethodInfo(context.Background(), tt.methodInfo) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + inv.ForMCPRequest(MCPMethodToolsCall, "restricted").RegisterTools(ctx, server, nil) + require.Equal(t, tt.wantCheckerCalls, checkerCalls) + if tt.legacyProtocol { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + if method == "server/discover" { + return nil, errors.New("legacy server does not support discovery") + } + return next(ctx, method, request) + } + }) + } + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, &mcp.ClientOptions{ + Capabilities: tt.clientCapabilities, + }) + clientSession, err := client.Connect(context.Background(), clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = clientSession.Close() }) + + result, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{Name: "restricted"}) + require.NoError(t, err) + require.Equal(t, tt.wantCheckerCalls, checkerCalls) + require.Equal(t, tt.wantHandlerCalls, handlerCalls) + if tt.wantError == "" { + assert.False(t, result.IsError) + return + } + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + content, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + assert.Equal(t, tt.wantError, content.Text) + }) + } +} + func availabilityTestTool(name, minimumProtocolVersion string, requiredElicitationMode ElicitationMode, onCall func()) ServerTool { return ServerTool{ Tool: mcp.Tool{ diff --git a/pkg/inventory/typed_input_test.go b/pkg/inventory/typed_input_test.go new file mode 100644 index 0000000000..29ae6bedb6 --- /dev/null +++ b/pkg/inventory/typed_input_test.go @@ -0,0 +1,110 @@ +package inventory + +import ( + "context" + "encoding/json" + "testing" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type typedValidationInput struct { + Mode string `json:"mode"` + Count int `json:"count"` +} + +func compareTypedInputWithSDK[In any](t *testing.T, advertised, validation *jsonschema.Schema) { + t.Helper() + type output struct { + Input In `json:"input"` + } + handler := func(_ context.Context, _ *mcp.CallToolRequest, input In) (*mcp.CallToolResult, output, error) { + return nil, output{Input: input}, nil + } + tool := NewServerToolWithContextHandlerAndSchemaOptions( + mcp.Tool{Name: "validation", InputSchema: advertised, OutputSchema: &jsonschema.Schema{Type: "object"}}, + testToolsetMetadata("test"), + handler, + TypedSchemaOptions{ValidationInputSchema: validation}, + ) + typedServer := mcp.NewServer(&mcp.Implementation{Name: "typed", Version: "test"}, nil) + tool.RegisterFunc(typedServer, nil) + typedSession := connectTypedTestClient(t, typedServer, "") + sdkServer := mcp.NewServer(&mcp.Implementation{Name: "sdk", Version: "test"}, nil) + mcp.AddTool(sdkServer, &mcp.Tool{ + Name: "validation", InputSchema: validation, OutputSchema: &jsonschema.Schema{Type: "object"}, + }, handler) + sdkSession := connectTypedTestClient(t, sdkServer, "") + for _, raw := range []string{ + `{"mode":"valid"}`, `{"mode":"valid","count":9}`, `{"mode":"valid","Count":9}`, + `{"mode":"invalid"}`, `{"mode":42}`, `{}`, `{"mode":"valid","count":"bad"}`, + `{"mode":"valid","owner":"octo"}`, `{"mode":"valid","owner":"invalid"}`, + `{"mode":"valid","owner":42}`, `{"mode":"valid","owner":null}`, + `[]`, `"not an object"`, + } { + t.Run(raw, func(t *testing.T) { + params := &mcp.CallToolParams{Name: "validation", Arguments: json.RawMessage(raw)} + control, err := sdkSession.CallTool(context.Background(), params) + require.NoError(t, err) + result, err := typedSession.CallTool(context.Background(), params) + require.NoError(t, err) + assert.Equal(t, control.IsError, result.IsError) + assert.JSONEq(t, mustMarshalJSON(t, control.StructuredContent), mustMarshalJSON(t, result.StructuredContent)) + assert.Equal(t, control.Content, result.Content) + if !result.IsError { + assert.Contains(t, mustMarshalJSON(t, result.StructuredContent), `"count":`) + } + }) + } + list, err := typedSession.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.JSONEq(t, mustMarshalJSON(t, advertised), mustMarshalJSON(t, list.Tools[0].InputSchema)) +} + +func TestTypedInputValidationMatchesSDK(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "mode": {Type: "string", Enum: []any{"valid"}}, + "count": {Type: "integer", Default: json.RawMessage(`2`)}, + }, + Required: []string{"mode"}, + } + t.Run("struct", func(t *testing.T) { compareTypedInputWithSDK[typedValidationInput](t, schema, schema) }) + t.Run("pointer", func(t *testing.T) { compareTypedInputWithSDK[*typedValidationInput](t, schema, schema) }) + t.Run("any", func(t *testing.T) { + compareTypedInputWithSDK[any](t, schema, schema) + }) + t.Run("runtime-schema", func(t *testing.T) { + runtime := CloneSchema(schema) + runtime.Properties["mode"].Enum = nil + runtime.Properties["count"].Default = json.RawMessage(`3`) + compareTypedInputWithSDK[typedValidationInput](t, schema, runtime) + }) + t.Run("header-annotations", func(t *testing.T) { + type input struct { + Owner string `json:"owner"` + typedValidationInput + } + advertised := CloneSchema(schema) + advertised.Properties["owner"] = &jsonschema.Schema{ + Type: "string", Enum: []any{"octo"}, Default: json.RawMessage(`"octo"`), + Extra: map[string]any{"x-mcp-header": "owner"}, + } + compareTypedInputWithSDK[input](t, advertised, advertised) + }) +} + +func TestResolvedTypedInputSchemaCache(t *testing.T) { + schema := &jsonschema.Schema{Type: "object", Properties: map[string]*jsonschema.Schema{"value": {Type: "string"}}} + first, err := cachedResolvedInputSchema(schema) + require.NoError(t, err) + second, err := cachedResolvedInputSchema(CloneSchema(schema)) + require.NoError(t, err) + assert.Same(t, first, second) + assert.Equal(t, "string", schema.Properties["value"].Type) +} diff --git a/pkg/inventory/typed_output.go b/pkg/inventory/typed_output.go new file mode 100644 index 0000000000..f6ec5d1ea2 --- /dev/null +++ b/pkg/inventory/typed_output.go @@ -0,0 +1,437 @@ +package inventory + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" + sdkjson "github.com/segmentio/encoding/json" +) + +type inputNormalizationContextKey struct{} +type preflightCompleteKey struct{} +type typedCallStateKey struct{} +type typedRegistrationSetContextKey struct{} + +type typedToolRegistrationSet struct { + byName map[string]*typedToolRegistration +} + +type typedCallState struct { + toolName string + handlerCalled bool + hasOutput bool + contentLengthBeforeSDK int + preserveContent bool + structuredOutput any + sdkCalled bool + shortCircuit *mcp.CallToolResult +} + +type typedToolRegistration struct { + name string + modernTool *mcp.Tool + legacyTool *mcp.Tool + modernRuntimeTool *mcp.Tool + legacyRuntimeTool *mcp.Tool + hasTypedOutput bool + inputNormalizer InputNormalizer + preflight ToolCallPreflight + fixedEra ProtocolEra + preserveContent bool + handlerMiddleware []ToolHandlerMiddleware +} + +func wrapTypedHandler[In, Out any](handler mcp.ToolHandlerFor[In, Out], inputSchema *jsonschema.Resolved) mcp.ToolHandlerFor[any, any] { + return func(ctx context.Context, req *mcp.CallToolRequest, value any) (*mcp.CallToolResult, any, error) { + if state, ok := ctx.Value(typedCallStateKey{}).(*typedCallState); ok && state.shortCircuit != nil { + return state.shortCircuit, nil, nil + } + if err := inputSchema.ApplyDefaults(&value); err != nil { + return typedInputErrorResult(fmt.Errorf("validating \"arguments\": applying schema defaults:\n%w", err)), nil, nil + } + if err := inputSchema.Validate(&value); err != nil { + return typedInputErrorResult(fmt.Errorf("validating \"arguments\": %w", err)), nil, nil + } + encoded, err := json.Marshal(value) + if err != nil { + return typedInputErrorResult(fmt.Errorf("validating \"arguments\": marshalling with defaults: %w", err)), nil, nil + } + var input In + decoder := sdkjson.NewDecoder(bytes.NewReader(encoded)) + decoder.DontMatchCaseInsensitiveStructFields() + if err := decoder.Decode(&input); err != nil { + return typedInputErrorResult(err), nil, nil + } + var output any + handlerCalled := false + next := func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + handlerCalled = true + result, typedOutput, err := handler(ctx, req, input) + if err == nil && (result == nil || (!result.IsError && result.InputRequests == nil)) { + output = typedOutput + } + return result, err + } + result, err := next(ctx, req) + if err != nil { + if rpcErr, ok := errors.AsType[*jsonrpc.Error](err); ok { + return nil, output, rpcErr + } + return nil, output, err + } + if result == nil { + result = &mcp.CallToolResult{} + } + if state, ok := ctx.Value(typedCallStateKey{}).(*typedCallState); ok { + state.handlerCalled = handlerCalled + state.hasOutput = handlerCalled && !result.IsError && result.InputRequests == nil + state.contentLengthBeforeSDK = len(result.Content) + } + if result.IsError || result.InputRequests != nil || !handlerCalled { + output = nil + } + return result, output, nil + } +} + +func typedInputErrorResult(err error) *mcp.CallToolResult { + result := &mcp.CallToolResult{} + result.SetError(err) + return result +} + +// typedOutputMiddleware selects immutable advertised schema variants and +// handles request-era compatibility without mutating registered tools. +func typedOutputMiddleware(registrationSet *typedToolRegistrationSet) mcp.Middleware { + if registrationSet == nil { + registrationSet = &typedToolRegistrationSet{byName: map[string]*typedToolRegistration{}} + } + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + switch req := request.(type) { + case *mcp.ListToolsRequest: + result, err := next(ctx, method, request) + if err != nil { + return nil, err + } + list, ok := result.(*mcp.ListToolsResult) + if !ok { + return result, nil + } + var requestVersion string + if req.Params != nil { + requestVersion = requestMetaProtocolVersion(req.Params.Meta) + } + era := requestEra(ctx, requestVersion) + for i, tool := range list.Tools { + registration := registrationSet.byName[tool.Name] + if registration == nil { + continue + } + selectedEra := era + if registration.fixedEra != ProtocolEraDynamic { + selectedEra = registration.fixedEra + } + if selectedEra == ProtocolEraModern { + list.Tools[i] = registration.modernTool + } else { + list.Tools[i] = registration.legacyTool + } + } + return list, nil + case *mcp.CallToolRequest: + if req.Params == nil { + return next(ctx, method, request) + } + registration := registrationSet.byName[req.Params.Name] + if registration == nil { + return next(ctx, method, request) + } + ctx, ownsRequest := claimTypedRegistrationSet(ctx, registrationSet) + if !ownsRequest { + return next(ctx, method, request) + } + state := &typedCallState{toolName: req.Params.Name} + ctx = context.WithValue(ctx, typedCallStateKey{}, state) + requestVersion := requestMetaProtocolVersion(req.Params.Meta) + dispatch := func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + request = req + + if registration != nil && registration.preflight != nil { + preflightContext, preflightResult, preflightErr := registration.preflight(ctx, req) + if preflightErr != nil || preflightResult != nil { + if preflightErr != nil { + if rpcErr, ok := errors.AsType[*jsonrpc.Error](preflightErr); ok { + return nil, rpcErr + } + if preflightResult == nil { + preflightResult = &mcp.CallToolResult{} + } + preflightResult.SetError(preflightErr) + } + era := requestEra(ctx, requestVersion) + if registration.fixedEra != ProtocolEraDynamic { + era = registration.fixedEra + } + if era == ProtocolEraModern { + if err := validateExplicitStructuredOutput(outputSchema(registration), preflightResult); err != nil { + return nil, err + } + } + return preflightResult, nil + } + if preflightContext != nil { + ctx = preflightContext + } + ctx = context.WithValue(ctx, typedCallStateKey{}, state) + ctx = context.WithValue(ctx, preflightCompleteKey{}, req.Params.Name) + } + + era := requestEra(ctx, requestVersion) + if registration != nil && registration.fixedEra != ProtocolEraDynamic { + era = registration.fixedEra + } + if registration != nil && registration.inputNormalizer != nil && !inputAlreadyNormalized(ctx, req.Params.Name) { + arguments := req.Params.Arguments + if len(arguments) == 0 { + arguments = json.RawMessage(`{}`) + } + var normalized json.RawMessage + if methodInfo, ok := ghcontext.MCPMethod(ctx); ok && + methodInfo.ArgumentsNormalized && methodInfo.ItemName == req.Params.Name { + normalized = methodInfo.NormalizedArguments + } else { + var normalizeErr error + normalized, normalizeErr = registration.inputNormalizer(arguments) + if normalizeErr != nil { + return invalidArgumentsResult(fmt.Errorf("normalize tool arguments: %w", normalizeErr)), nil + } + } + requestCopy := *req + paramsCopy := *req.Params + paramsCopy.Arguments = normalized + requestCopy.Params = ¶msCopy + request = &requestCopy + ctx = context.WithValue(ctx, inputNormalizationContextKey{}, req.Params.Name) + } + + state.sdkCalled = true + result, err := next(ctx, method, request) + if err != nil { + return nil, err + } + callResult, ok := result.(*mcp.CallToolResult) + if !ok { + return nil, fmt.Errorf("unexpected tools/call result type %T", result) + } + if registration != nil && registration.hasTypedOutput && state.hasOutput { + state.structuredOutput = callResult.StructuredContent + resultCopy := *callResult + if err := removeSDKOutputFallback(&resultCopy, state); err != nil { + return nil, err + } + callResult = &resultCopy + if era != ProtocolEraModern { + legacyResult := *callResult + legacyResult.StructuredContent = nil + callResult = &legacyResult + } + } else if registration != nil && registration.hasTypedOutput && !state.handlerCalled && era == ProtocolEraModern { + if err := validateExplicitStructuredOutput(outputSchema(registration), callResult); err != nil { + return nil, err + } + } + if registration.hasTypedOutput && era != ProtocolEraModern && callResult.StructuredContent != nil { + legacyResult := *callResult + legacyResult.StructuredContent = nil + callResult = &legacyResult + } + return callResult, nil + } + callResult, err := applyToolHandlerMiddleware(dispatch, registration.handlerMiddleware...)(ctx, req) + if err != nil { + return nil, err + } + if registration.hasTypedOutput && !state.sdkCalled && callResult != nil { + // Guards and preflight run before decoding, but their results + // must still pass through the SDK's tool-call finalization. + state.shortCircuit = callResult + requestCopy := *req + paramsCopy := *req.Params + paramsCopy.Arguments = json.RawMessage(`{}`) + requestCopy.Params = ¶msCopy + result, err := next(ctx, method, &requestCopy) + if err != nil { + return nil, err + } + var ok bool + callResult, ok = result.(*mcp.CallToolResult) + if !ok { + return nil, fmt.Errorf("unexpected tools/call result type %T", result) + } + } + era := requestEra(ctx, requestVersion) + if registration.fixedEra != ProtocolEraDynamic { + era = registration.fixedEra + } + if registration.hasTypedOutput && callResult != nil { + if era != ProtocolEraModern { + resultCopy := *callResult + resultCopy.StructuredContent = nil + callResult = &resultCopy + } else { + if state.hasOutput && state.preserveContent && !callResult.IsError { + resultCopy := *callResult + resultCopy.StructuredContent = state.structuredOutput + callResult = &resultCopy + } + if !state.handlerCalled { + if err := validateExplicitStructuredOutput(outputSchema(registration), callResult); err != nil { + return nil, err + } + } + if !registration.preserveContent && !state.preserveContent { + if err := applyModernStructuredText(callResult); err != nil { + return nil, err + } + } + } + } + return callResult, nil + default: + return next(ctx, method, request) + } + } + } +} + +func claimTypedRegistrationSet(ctx context.Context, registrationSet *typedToolRegistrationSet) (context.Context, bool) { + if active, ok := ctx.Value(typedRegistrationSetContextKey{}).(*typedToolRegistrationSet); ok { + return ctx, active == registrationSet + } + return context.WithValue(ctx, typedRegistrationSetContextKey{}, registrationSet), true +} + +func outputSchema(registration *typedToolRegistration) *jsonschema.Schema { + if registration == nil || registration.modernTool == nil { + return nil + } + if schema, ok := registration.modernTool.OutputSchema.(*jsonschema.Schema); ok { + return schema + } + if raw, ok := registration.modernTool.OutputSchema.(json.RawMessage); ok { + var schema jsonschema.Schema + if json.Unmarshal(raw, &schema) == nil { + return &schema + } + } + return nil +} + +func validateExplicitStructuredOutput(schema *jsonschema.Schema, result *mcp.CallToolResult) error { + if schema == nil || result == nil || result.IsError || result.InputRequests != nil || result.StructuredContent == nil { + return nil + } + encoded, err := json.Marshal(result.StructuredContent) + if err != nil { + return fmt.Errorf("marshal explicit structured tool output: %w", err) + } + var value any + if err := json.Unmarshal(encoded, &value); err != nil { + return fmt.Errorf("decode explicit structured tool output: %w", err) + } + resolved, err := schema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) + if err != nil { + return fmt.Errorf("resolve tool output schema: %w", err) + } + if err := resolved.Validate(value); err != nil { + return fmt.Errorf("validating tool output: %w", err) + } + return nil +} + +func applyModernStructuredText(result *mcp.CallToolResult) error { + if result == nil || result.IsError || result.InputRequests != nil || result.StructuredContent == nil { + return nil + } + encoded, err := json.Marshal(result.StructuredContent) + if err != nil { + return fmt.Errorf("marshal structured tool output for text content: %w", err) + } + if len(result.Content) > 0 { + for _, content := range result.Content { + if _, ok := content.(*mcp.TextContent); !ok { + return nil + } + } + } + result.Content = []mcp.Content{&mcp.TextContent{Text: string(encoded)}} + return nil +} + +func removeSDKOutputFallback(result *mcp.CallToolResult, state *typedCallState) error { + if result.StructuredContent == nil || len(result.Content) <= state.contentLengthBeforeSDK { + return nil + } + encoded, err := json.Marshal(result.StructuredContent) + if err != nil { + return fmt.Errorf("marshal typed tool output while removing fallback content: %w", err) + } + removeMatchingLastTextContent(result, encoded) + return nil +} + +func removeMatchingLastTextContent(result *mcp.CallToolResult, encoded []byte) { + if len(result.Content) == 0 { + return + } + last, ok := result.Content[len(result.Content)-1].(*mcp.TextContent) + if !ok || !bytes.Equal(bytes.TrimSpace([]byte(last.Text)), bytes.TrimSpace(encoded)) { + return + } + result.Content = result.Content[:len(result.Content)-1] +} + +func requestEra(ctx context.Context, requestVersion string) ProtocolEra { + if requestVersion != "" { + return ProtocolEraForVersion(requestVersion) + } + if info, ok := ghcontext.MCPMethod(ctx); ok && info != nil { + return ProtocolEraForVersion(info.ProtocolVersion) + } + return ProtocolEraLegacy +} + +func requestMetaProtocolVersion(meta mcp.Meta) string { + version, _ := meta[mcp.MetaKeyProtocolVersion].(string) + return version +} + +func preflightAlreadyRun(ctx context.Context, toolName string) bool { + name, ok := ctx.Value(preflightCompleteKey{}).(string) + return ok && name == toolName +} + +func markPreflightRun(ctx context.Context, toolName string) context.Context { + return context.WithValue(ctx, preflightCompleteKey{}, toolName) +} + +func inputAlreadyNormalized(ctx context.Context, toolName string) bool { + name, ok := ctx.Value(inputNormalizationContextKey{}).(string) + return ok && name == toolName +} + +// PreserveToolHandlerContent marks a typed call's handler content as +// intentionally non-JSON (for example CSV or resource blocks). +func PreserveToolHandlerContent(ctx context.Context) { + if state, ok := ctx.Value(typedCallStateKey{}).(*typedCallState); ok { + state.preserveContent = true + } +} diff --git a/pkg/inventory/typed_output_test.go b/pkg/inventory/typed_output_test.go new file mode 100644 index 0000000000..99b6b30ed3 --- /dev/null +++ b/pkg/inventory/typed_output_test.go @@ -0,0 +1,2070 @@ +package inventory + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "slices" + "strconv" + "strings" + "sync" + "testing" + + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type typedTestInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Query string `json:"query" jsonschema:"The search query"` +} + +type typedTestOutput struct { + Query string `json:"query"` +} + +func TestTypedExplicitErrorOutputProtocolGate(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "query": {Type: "string"}, + }, + Required: []string{"query"}, + } + registration := &typedToolRegistration{ + name: "pending", + modernTool: &mcp.Tool{Name: "pending", OutputSchema: schema}, + legacyTool: &mcp.Tool{Name: "pending"}, + hasTypedOutput: true, + } + middleware := typedOutputMiddleware(&typedToolRegistrationSet{ + byName: map[string]*typedToolRegistration{"pending": registration}, + }) + + for _, tc := range []struct { + name string + protocol string + result *mcp.CallToolResult + wantStructured any + wantInputRequests bool + }{ + { + name: "modern explicit awaiting status", + protocol: ProtocolVersionMultiRoundTrip, + result: &mcp.CallToolResult{ + IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "wait for input"}}, + StructuredContent: typedTestOutput{Query: "awaiting"}, + }, + wantStructured: typedTestOutput{Query: "awaiting"}, + }, + { + name: "legacy omits awaiting status", + protocol: "2025-11-25", + result: &mcp.CallToolResult{ + IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "wait for input"}}, + StructuredContent: typedTestOutput{Query: "awaiting"}, + }, + }, + { + name: "ordinary typed error", + protocol: ProtocolVersionMultiRoundTrip, + result: &mcp.CallToolResult{ + IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "request failed"}}, + }, + }, + { + name: "modern elicitation retains status and request", + protocol: ProtocolVersionMultiRoundTrip, + result: &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "wait for input"}}, + StructuredContent: typedTestOutput{Query: "awaiting"}, + InputRequests: mcp.InputRequestMap{"form": &mcp.ElicitParams{Mode: "form"}}, + }, + wantStructured: typedTestOutput{Query: "awaiting"}, + wantInputRequests: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + handler := middleware(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return tc.result, nil + }) + result, err := handler(context.Background(), MCPMethodToolsCall, &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Name: "pending", + Meta: mcp.Meta{mcp.MetaKeyProtocolVersion: tc.protocol}, + }, + }) + require.NoError(t, err) + callResult, ok := result.(*mcp.CallToolResult) + require.True(t, ok) + require.True(t, callResult.IsError || callResult.InputRequests != nil) + assert.Equal(t, tc.wantStructured, callResult.StructuredContent) + if tc.wantInputRequests { + assert.Equal(t, tc.result.InputRequests, callResult.InputRequests) + } else { + assert.Nil(t, callResult.InputRequests) + } + assert.Equal(t, tc.result.Content, callResult.Content) + }) + } +} + +func TestTypedToolRegistrationInfersSchemasAndValidates(t *testing.T) { + handlerCalls := 0 + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_tool"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, input typedTestInput) (*mcp.CallToolResult, typedTestOutput, error) { + handlerCalls++ + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "old text result"}}, + }, typedTestOutput{Query: input.Query}, nil + }, + ) + require.Nil(t, tool.Tool.InputSchema) + require.Nil(t, tool.Tool.OutputSchema) + + inv, err := NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + inv.RegisterTools(context.Background(), server, nil) + session := connectTypedTestClient(t, server, "") + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.NotNil(t, list.Tools[0].InputSchema) + assert.NotNil(t, list.Tools[0].OutputSchema) + var inferredSchema map[string]any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, list.Tools[0].InputSchema)), &inferredSchema)) + properties := inferredSchema["properties"].(map[string]any) + assert.Equal(t, "owner", properties["owner"].(map[string]any)["x-mcp-header"]) + assert.Equal(t, "repo", properties["repo"].(map[string]any)["x-mcp-header"]) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "typed_tool", + Arguments: map[string]any{"owner": "octo", "repo": "hello", "query": "is:open"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, 1, handlerCalls) + require.NotNil(t, result.StructuredContent) + assert.JSONEq(t, `{"query":"is:open"}`, mustMarshalJSON(t, result.StructuredContent)) + require.Len(t, result.Content, 1) + assert.JSONEq(t, `{"query":"is:open"}`, result.Content[0].(*mcp.TextContent).Text) + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "typed_tool", + Arguments: map[string]any{"owner": "octo", "repo": "hello", "query": 42}, + }) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Equal(t, 1, handlerCalls, "SDK validation must reject invalid arguments before calling the handler") +} + +func TestTypedToolLegacySchemaRetainsHeaderRoutingMetadata(t *testing.T) { + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "legacy_routing_tool"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, input typedTestInput) (*mcp.CallToolResult, typedTestOutput, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "legacy text result"}}, + }, typedTestOutput{Query: input.Query}, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "2025-11-25") + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.Nil(t, list.Tools[0].OutputSchema) + var inputSchema map[string]any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, list.Tools[0].InputSchema)), &inputSchema)) + properties := inputSchema["properties"].(map[string]any) + assert.Equal(t, "owner", properties["owner"].(map[string]any)["x-mcp-header"]) + assert.Equal(t, "repo", properties["repo"].(map[string]any)["x-mcp-header"]) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "legacy_routing_tool", + Arguments: map[string]any{"owner": "octo", "repo": "hello", "query": "is:open"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1) + assert.Equal(t, "legacy text result", result.Content[0].(*mcp.TextContent).Text) +} + +func TestTypedToolInferredInputSchemaUsesStablePointer(t *testing.T) { + type input struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + } + first, err := CachedInputSchemaFor[input](nil) + require.NoError(t, err) + second, err := CachedInputSchemaFor[input](nil) + require.NoError(t, err) + assert.Same(t, first, second, "schema inference must be cached across tool/server instances") + inferredSchema := first + assert.Equal(t, "owner", inferredSchema.Properties["owner"].Extra["x-mcp-header"]) + assert.Equal(t, "repo", inferredSchema.Properties["repo"].Extra["x-mcp-header"]) +} + +func TestTypedSchemasAreCachedAcrossFreshRegistrations(t *testing.T) { + type input struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + } + type output struct { + State string `json:"state"` + } + var firstInput, firstOutput *jsonschema.Schema + for range 2 { + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "cached_typed_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, input) (*mcp.CallToolResult, output, error) { + return nil, output{}, nil + }, + ) + registration := tool.typedRegistration(&tool.Tool) + modernInput := registration.modernTool.InputSchema.(*jsonschema.Schema) + legacyInput := registration.legacyTool.InputSchema.(*jsonschema.Schema) + assert.Same(t, modernInput, legacyInput, "both eras must share the pre-annotated input schema") + assert.Same(t, modernInput, registration.modernRuntimeTool.InputSchema) + assert.Same(t, modernInput, registration.legacyRuntimeTool.InputSchema) + assert.Nil(t, registration.legacyTool.OutputSchema) + assert.Nil(t, registration.legacyRuntimeTool.OutputSchema) + if firstInput == nil { + firstInput = modernInput + firstOutput = registration.modernTool.OutputSchema.(*jsonschema.Schema) + continue + } + assert.Same(t, firstInput, modernInput) + assert.Same(t, firstOutput, registration.modernTool.OutputSchema) + } +} + +func TestCachedExplicitSchemaWarmPathsPreserveOwnership(t *testing.T) { + original := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string", Description: "original owner"}, + }, + } + cached, err := CachedSchema(original) + require.NoError(t, err) + require.NotSame(t, original, cached) + original.Properties["owner"].Description = "changed owner" + assert.Equal(t, "original owner", cached.Properties["owner"].Description) + changed, err := CachedSchema(original) + require.NoError(t, err) + assert.NotSame(t, cached, changed, "caller-owned pointers must retain content-based lookup") + + tool := mcp.Tool{InputSchema: cached} + AnnotateHeaderParams(&tool) + annotated := tool.InputSchema.(*jsonschema.Schema) + assert.NotSame(t, cached, annotated) + assert.Empty(t, cached.Properties["owner"].Extra, "header preparation must not mutate the advertised schema") + assert.Equal(t, "owner", annotated.Properties["owner"].Extra["x-mcp-header"]) + + var cacheErr error + allocations := testing.AllocsPerRun(10, func() { + warm := mcp.Tool{InputSchema: cached} + AnnotateHeaderParams(&warm) + var prepared *jsonschema.Schema + prepared, cacheErr = CachedSchema(warm.InputSchema.(*jsonschema.Schema)) + if prepared != annotated { + panic("warm registration changed the cached schema pointer") + } + }) + require.NoError(t, cacheErr) + assert.Zero(t, allocations, "cache-owned warm schemas must avoid canonical serialization and annotation cloning") +} + +func TestProtocolEraForSupportedVersions(t *testing.T) { + supported := []string{"2027-01-01", ProtocolVersionMultiRoundTrip, "2025-11-25", "2027-02-30", "garbage"} + for _, tc := range []struct { + name string + version string + want ProtocolEra + }{ + {name: "exact threshold", version: ProtocolVersionMultiRoundTrip, want: ProtocolEraModern}, + {name: "supported later", version: "2027-01-01", want: ProtocolEraModern}, + {name: "older", version: "2025-11-25", want: ProtocolEraLegacy}, + {name: "unsupported future", version: "2099-01-01", want: ProtocolEraLegacy}, + {name: "invalid date even if listed", version: "2027-02-30", want: ProtocolEraLegacy}, + {name: "garbage even if listed", version: "garbage", want: ProtocolEraLegacy}, + {name: "absent", want: ProtocolEraLegacy}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, protocolEraForSupportedVersion(tc.version, supported)) + }) + } + assert.Equal(t, ProtocolEraLegacy, protocolEraForSupportedVersion(ProtocolVersionMultiRoundTrip, nil), + "a valid modern date must still be supported") + + for _, version := range mcp.SupportedProtocolVersions() { + want := ProtocolEraLegacy + if version >= ProtocolVersionMultiRoundTrip { + want = ProtocolEraModern + } + assert.Equal(t, want, ProtocolEraForVersion(version), "SDK-supported version %q", version) + } + for _, version := range []string{"", "garbage", "2027-02-30", "2099-01-01"} { + assert.Equal(t, ProtocolEraLegacy, ProtocolEraForVersion(version), "unknown version %q", version) + } +} + +func TestTypedPointerInputUsesObjectSchemaInBothEras(t *testing.T) { + type input struct { + Query string `json:"query"` + } + valueSchema, err := CachedInputSchemaFor[input](nil) + require.NoError(t, err) + pointerSchema, err := CachedInputSchemaFor[*input](nil) + require.NoError(t, err) + assert.Same(t, valueSchema, pointerSchema, "SDK inference unwraps one input pointer before caching") + assert.Equal(t, "object", pointerSchema.Type) + assert.Empty(t, pointerSchema.Types) + + outputSchema, err := CachedSchemaFor[*input](nil) + require.NoError(t, err) + assert.Contains(t, outputSchema.Types, "null", "output pointers must retain nullability") + + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "pointer_input"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, args *input) (*mcp.CallToolResult, typedTestOutput, error) { + require.NotNil(t, args) + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: args.Query}}}, + typedTestOutput{Query: args.Query}, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1"}, nil) + require.NotPanics(t, func() { tool.RegisterFunc(server, nil) }) + for _, version := range []string{"", "2025-11-25"} { + session := connectTypedTestClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.Contains(t, mustMarshalJSON(t, list.Tools[0].InputSchema), `"type":"object"`) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"query": "pointer input decoded"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + if version == "" { + assert.JSONEq(t, `{"query":"pointer input decoded"}`, mustMarshalJSON(t, result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1) + assert.Equal(t, "pointer input decoded", result.Content[0].(*mcp.TextContent).Text) + } + } +} + +func TestCachedSchemaDeepCopiesMutableMetadata(t *testing.T) { + constant := any(map[string]any{"nested": []string{"const"}}) + nullConstant := any(nil) + original := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "state": { + Type: "string", + Enum: []any{map[string]any{"values": []string{"open"}}}, + Extra: map[string]any{ + "nested": map[string]any{"values": []string{"property"}}, + }, + }, + }, + Enum: []any{map[string]any{"values": []string{"root"}}}, + Const: &constant, + Examples: []any{map[string]any{"values": []string{"example"}}}, + DependencyStrings: map[string][]string{ + "state": {"repo"}, + }, + PropertyOrder: []string{"state"}, + Extra: map[string]any{ + "nested": map[string]any{"values": []string{"extra"}}, + }, + } + cached, err := CachedSchema(original) + require.NoError(t, err) + + original.Enum[0].(map[string]any)["values"].([]string)[0] = "mutated-root" + original.Properties["state"].Enum[0].(map[string]any)["values"].([]string)[0] = "mutated-property" + original.Properties["state"].Extra["nested"].(map[string]any)["values"].([]string)[0] = "mutated-property-extra" + (*original.Const).(map[string]any)["nested"].([]string)[0] = "mutated-const" + original.Examples[0].(map[string]any)["values"].([]string)[0] = "mutated-example" + original.DependencyStrings["state"][0] = "mutated-dependency" + original.PropertyOrder[0] = "mutated-order" + original.Extra["nested"].(map[string]any)["values"].([]string)[0] = "mutated-extra" + + assert.Equal(t, "root", cached.Enum[0].(map[string]any)["values"].([]string)[0]) + assert.Equal(t, "open", cached.Properties["state"].Enum[0].(map[string]any)["values"].([]string)[0]) + assert.Equal(t, "property", cached.Properties["state"].Extra["nested"].(map[string]any)["values"].([]string)[0]) + assert.Equal(t, "const", (*cached.Const).(map[string]any)["nested"].([]string)[0]) + assert.Equal(t, "example", cached.Examples[0].(map[string]any)["values"].([]string)[0]) + assert.Equal(t, []string{"repo"}, cached.DependencyStrings["state"]) + assert.Equal(t, []string{"state"}, cached.PropertyOrder) + assert.Equal(t, "extra", cached.Extra["nested"].(map[string]any)["values"].([]string)[0]) + nullSchema, err := CachedSchema(&jsonschema.Schema{Const: &nullConstant}) + require.NoError(t, err) + require.NotNil(t, nullSchema.Const) + assert.Nil(t, *nullSchema.Const) + + var workers sync.WaitGroup + workers.Add(2) + go func() { + defer workers.Done() + for range 100 { + original.Properties["state"].Extra["nested"].(map[string]any)["values"].([]string)[0] = "concurrent mutation" + } + }() + go func() { + defer workers.Done() + for range 100 { + if _, err := json.Marshal(cached); err != nil { + t.Errorf("marshal cached schema: %v", err) + return + } + } + }() + workers.Wait() +} + +func TestSchemaClonesDetachEveryNumericKeyword(t *testing.T) { + for name, clone := range map[string]func(*jsonschema.Schema) *jsonschema.Schema{ + "direct": CloneSchema, + "cache": func(schema *jsonschema.Schema) *jsonschema.Schema { + cached, err := CachedSchema(schema) + require.NoError(t, err) + return cached + }, + "inference options": func(schema *jsonschema.Schema) *jsonschema.Schema { + options := cloneJSONSchemaForOptions(&jsonschema.ForOptions{ + TypeSchemas: map[reflect.Type]*jsonschema.Schema{reflect.TypeFor[int](): schema}, + }) + return options.TypeSchemas[reflect.TypeFor[int]()] + }, + "without defaults": CloneSchemaWithoutDefaults, + } { + t.Run(name, func(t *testing.T) { + bounds := &jsonschema.Schema{} + fields := reflect.ValueOf(bounds).Elem() + var numericFields []int + for i := range fields.NumField() { + field := fields.Field(i) + if field.Type() != reflect.TypeFor[*int]() && field.Type() != reflect.TypeFor[*float64]() { + continue + } + value := reflect.New(field.Type().Elem()) + if value.Elem().Kind() == reflect.Int { + value.Elem().SetInt(11) + } else { + value.Elem().SetFloat(11) + } + field.Set(value) + numericFields = append(numericFields, i) + } + require.NotEmpty(t, numericFields) + original := &jsonschema.Schema{ + Type: "object", + Comment: "numeric keyword isolation " + name, + Properties: map[string]*jsonschema.Schema{"bounds": bounds}, + } + cloned := clone(original) + clonedFields := reflect.ValueOf(cloned.Properties["bounds"]).Elem() + for _, i := range numericFields { + keyword := fields.Type().Field(i).Name + assert.NotEqual(t, fields.Field(i).Pointer(), clonedFields.Field(i).Pointer(), keyword) + assert.Equal(t, fields.Field(i).Elem().Interface(), clonedFields.Field(i).Elem().Interface(), keyword) + } + + // Mutating caller-owned bounds must not race with cached-schema reads. + var workers sync.WaitGroup + workers.Add(2) + go func() { + defer workers.Done() + for range 100 { + for _, i := range numericFields { + value := fields.Field(i).Elem() + if value.Kind() == reflect.Int { + value.SetInt(22) + } else { + value.SetFloat(22) + } + } + } + }() + go func() { + defer workers.Done() + for range 100 { + if _, err := json.Marshal(cloned); err != nil { + t.Errorf("marshal detached schema: %v", err) + return + } + } + }() + workers.Wait() + for _, i := range numericFields { + value := clonedFields.Field(i).Elem() + if value.Kind() == reflect.Int { + assert.Equal(t, int64(11), value.Int()) + } else { + assert.Equal(t, float64(11), value.Float()) + } + } + assert.Nil(t, cloned.Minimum, "absent bounds must remain absent") + }) + } +} + +func TestCloneSchemaWithoutDefaultsTraversesAllChildKinds(t *testing.T) { + schemaType := reflect.TypeFor[jsonschema.Schema]() + for i := range schemaType.NumField() { + field := schemaType.Field(i) + if field.Type != reflect.TypeFor[*jsonschema.Schema]() && + field.Type != reflect.TypeFor[[]*jsonschema.Schema]() && + field.Type != reflect.TypeFor[map[string]*jsonschema.Schema]() { + continue + } + t.Run(field.Name, func(t *testing.T) { + original := &jsonschema.Schema{Default: json.RawMessage(`1`)} + // A single deep chain makes repeated per-level traversal exponential. + const depth = 48 + for range depth { + parent := &jsonschema.Schema{Default: json.RawMessage(`1`)} + target := reflect.ValueOf(parent).Elem().Field(i) + switch field.Type { + case reflect.TypeFor[*jsonschema.Schema](): + target.Set(reflect.ValueOf(original)) + case reflect.TypeFor[[]*jsonschema.Schema](): + target.Set(reflect.ValueOf([]*jsonschema.Schema{original})) + case reflect.TypeFor[map[string]*jsonschema.Schema](): + target.Set(reflect.ValueOf(map[string]*jsonschema.Schema{"child": original})) + } + original = parent + } + cloned := CloneSchemaWithoutDefaults(original) + for level := 0; level <= depth; level++ { + require.NotSame(t, original, cloned) + assert.Equal(t, json.RawMessage(`1`), original.Default) + assert.Nil(t, cloned.Default) + var originalChild, clonedChild *jsonschema.Schema + for _, child := range schemaChildren(original) { + if child != nil { + originalChild = child + } + } + for _, child := range schemaChildren(cloned) { + if child != nil { + clonedChild = child + } + } + original, cloned = originalChild, clonedChild + } + assert.Nil(t, original) + assert.Nil(t, cloned) + }) + } +} + +func TestSchemaInferenceOptionsDeepCopyOverrides(t *testing.T) { + type input struct { + State string `json:"state"` + } + original := &jsonschema.Schema{ + Type: "string", + Enum: []any{map[string]any{"values": []string{"open"}}}, + Extra: map[string]any{ + "nested": map[string]any{"values": []string{"option"}}, + }, + } + options := cloneJSONSchemaForOptions(&jsonschema.ForOptions{ + TypeSchemas: map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[input](): original, + }, + }) + original.Enum[0].(map[string]any)["values"].([]string)[0] = "mutated" + original.Extra["nested"].(map[string]any)["values"].([]string)[0] = "mutated" + + cloned := options.TypeSchemas[reflect.TypeFor[input]()] + assert.Equal(t, "open", cloned.Enum[0].(map[string]any)["values"].([]string)[0]) + assert.Equal(t, "option", cloned.Extra["nested"].(map[string]any)["values"].([]string)[0]) +} + +func TestRawExplicitSchemasReuseAnnotatedPointersAcrossRegistrations(t *testing.T) { + original := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + }, + } + tool := NewServerTool( + mcp.Tool{Name: "cached_raw_tool", InputSchema: original}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{}, nil + }, + ) + var first *jsonschema.Schema + for range 2 { + toolCopy := tool.Tool + AnnotateHeaderParams(&toolCopy) + registration := tool.typedRegistration(&toolCopy) + schema := registration.modernRuntimeTool.InputSchema.(*jsonschema.Schema) + if first == nil { + first = schema + } else { + assert.Same(t, first, schema) + } + } + assert.Empty(t, original.Properties["owner"].Extra) + assert.Empty(t, tool.Tool.InputSchema.(*jsonschema.Schema).Properties["owner"].Extra, + "constructor caching must not change the public definition's schema metadata") +} + +func TestTypedAnyInputPreservesSDKDefaultObjectSchemaInBothEras(t *testing.T) { + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_any_input"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, input any) (*mcp.CallToolResult, typedTestOutput, error) { + values, ok := input.(map[string]any) + require.True(t, ok) + return nil, typedTestOutput{Query: values["query"].(string)}, nil + }, + ) + require.Nil(t, tool.Tool.InputSchema, "the caller-owned static definition remains untouched") + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1"}, nil) + tool.RegisterFunc(server, nil) + modern := connectTypedTestClient(t, server, "") + legacy := connectTypedTestClient(t, server, "2025-11-25") + + for _, session := range []*mcp.ClientSession{modern, legacy} { + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.JSONEq(t, `{"type":"object"}`, mustMarshalJSON(t, list.Tools[0].InputSchema)) + } + list, err := modern.ListTools(context.Background(), nil) + require.NoError(t, err) + assert.NotNil(t, list.Tools[0].OutputSchema) + list, err = legacy.ListTools(context.Background(), nil) + require.NoError(t, err) + assert.Nil(t, list.Tools[0].OutputSchema) + + for _, tc := range []struct { + name string + session *mcp.ClientSession + modern bool + }{ + {name: "modern call", session: modern, modern: true}, + {name: "legacy call", session: legacy}, + } { + t.Run(tc.name, func(t *testing.T) { + result, err := tc.session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "typed_any_input", + Arguments: map[string]any{"query": "accepted"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + if tc.modern { + assert.JSONEq(t, `{"query":"accepted"}`, mustMarshalJSON(t, result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedToolInputSchemasPreserveObjectOptionality(t *testing.T) { + type optionalPointerInput struct { + Owner string `json:"owner"` + Filter *string `json:"filter,omitempty"` + Limit int `json:"limit,omitempty"` + } + + const emptyInputSchema = `{"type":"object"}` + const optionalPointerInputSchema = `{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "filter":{"type":"string"}, + "limit":{"type":"integer"} + }, + "required":["owner"] + }` + + legacyEmptyTool := NewServerTool( + mcp.Tool{Name: "legacy_empty_input", InputSchema: json.RawMessage(emptyInputSchema)}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil + }, + ) + typedEmptyTool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_empty_input"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, struct{}, error) { + return nil, struct{}{}, nil + }, + ) + legacyOptionalTool := NewServerTool( + mcp.Tool{Name: "legacy_optional_input", InputSchema: json.RawMessage(optionalPointerInputSchema)}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil + }, + ) + typedOptionalTool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_optional_input"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, optionalPointerInput) (*mcp.CallToolResult, struct{}, error) { + return nil, struct{}{}, nil + }, + ) + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + legacyEmptyTool.RegisterFunc(server, nil) + typedEmptyTool.RegisterFunc(server, nil) + legacyOptionalTool.RegisterFunc(server, nil) + typedOptionalTool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + + schemasByName := make(map[string]any, len(list.Tools)) + for _, tool := range list.Tools { + schemasByName[tool.Name] = tool.InputSchema + } + + for _, tc := range []struct { + legacyName string + typedName string + wantSchema string + wantProperties []string + wantRequired []string + }{ + {legacyName: "legacy_empty_input", typedName: "typed_empty_input", wantSchema: emptyInputSchema}, + { + legacyName: "legacy_optional_input", + typedName: "typed_optional_input", + wantSchema: optionalPointerInputSchema, + wantProperties: []string{"filter", "limit", "owner"}, + wantRequired: []string{"owner"}, + }, + } { + t.Run(tc.typedName, func(t *testing.T) { + legacySchema, ok := schemasByName[tc.legacyName] + require.True(t, ok) + typedSchema, ok := schemasByName[tc.typedName] + require.True(t, ok) + + legacyJSON := mustMarshalJSON(t, legacySchema) + typedJSON := mustMarshalJSON(t, typedSchema) + + legacyShape := readInputSchemaShape(t, legacyJSON) + typedShape := readInputSchemaShape(t, typedJSON) + expectedShape := readInputSchemaShape(t, tc.wantSchema) + assert.Equal(t, expectedShape, legacyShape, "fixture must represent the pre-migration input contract") + assert.Equal(t, legacyShape, typedShape, "typed registration must preserve the pre-migration input contract") + + assert.Equal(t, []string{"object"}, typedShape.rootTypes, "typed input roots must remain objects") + assert.False(t, typedShape.hasAnyOf, "input roots must not become nullable or optional unions") + assert.Equal(t, tc.wantProperties, typedShape.properties, "typed registration must preserve the input properties") + assert.ElementsMatch(t, tc.wantRequired, typedShape.required, "typed registration must preserve required properties") + }) + } +} + +type inputSchemaShape struct { + rootTypes []string + properties []string + required []string + hasAnyOf bool +} + +func readInputSchemaShape(t *testing.T, schemaJSON string) inputSchemaShape { + t.Helper() + var root map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(schemaJSON), &root)) + var schemaValue any + require.NoError(t, json.Unmarshal([]byte(schemaJSON), &schemaValue)) + + shape := inputSchemaShape{} + rawType, ok := root["type"] + require.True(t, ok, "schema must declare a root type") + if len(rawType) > 0 && rawType[0] == '[' { + require.NoError(t, json.Unmarshal(rawType, &shape.rootTypes)) + } else { + var rootType string + require.NoError(t, json.Unmarshal(rawType, &rootType)) + shape.rootTypes = []string{rootType} + } + shape.hasAnyOf = schemaContainsAnyOf(schemaValue) + + var properties map[string]json.RawMessage + if rawProperties, ok := root["properties"]; ok { + require.NoError(t, json.Unmarshal(rawProperties, &properties)) + for name := range properties { + shape.properties = append(shape.properties, name) + } + } + slices.Sort(shape.properties) + + if rawRequired, ok := root["required"]; ok { + require.NoError(t, json.Unmarshal(rawRequired, &shape.required)) + } + return shape +} + +func schemaContainsAnyOf(value any) bool { + switch value := value.(type) { + case map[string]any: + if _, ok := value["anyOf"]; ok { + return true + } + for _, child := range value { + if schemaContainsAnyOf(child) { + return true + } + } + case []any: + return slices.ContainsFunc(value, schemaContainsAnyOf) + } + return false +} + +func TestTypedToolRegistrationAppliesExplicitSchemas(t *testing.T) { + type input struct { + Mode string `json:"mode,omitempty"` + } + type output struct { + Mode string `json:"mode,omitempty"` + Count int `json:"count"` + } + + tool := NewServerToolWithContextHandler( + mcp.Tool{ + Name: "explicit_schema_tool", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{"mode":{"type":"string","enum":["wide","narrow"],"default":"wide"}}, + "required":["mode"] + }`), + OutputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "mode":{"type":"string","enum":["wide","narrow"],"default":"wide"}, + "count":{"type":"integer","minimum":2} + }, + "required":["mode","count"] + }`), + }, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, args input) (*mcp.CallToolResult, output, error) { + count := 3 + if args.Mode == "narrow" { + count = 1 + } + return nil, output{Count: count, Mode: args.Mode}, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + assert.Contains(t, mustMarshalJSON(t, list.Tools[0].InputSchema), `"default":"wide"`) + assert.Contains(t, mustMarshalJSON(t, list.Tools[0].OutputSchema), `"minimum":2`) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "explicit_schema_tool", + Arguments: map[string]any{"mode": "wide"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, `{"mode":"wide","count":3}`, mustMarshalJSON(t, result.StructuredContent)) + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "explicit_schema_tool", + Arguments: map[string]any{"mode": "invalid"}, + }) + require.NoError(t, err) + assert.True(t, result.IsError) + + _, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "explicit_schema_tool", + Arguments: map[string]any{"mode": "narrow"}, + }) + require.ErrorContains(t, err, "validating tool output") +} + +func TestTypedSchemaOptionsKeepAdvertisedSchemaAndRuntimeValidationSeparate(t *testing.T) { + type input struct { + State string `json:"state"` + } + type output struct { + State string `json:"state"` + } + publicSchema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "state": {Type: "string", Enum: []any{"open"}}, + }, + Required: []string{"state"}, + } + validationSchema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "state": {Type: "string"}, + }, + Required: []string{"state"}, + } + tool := NewServerToolWithContextHandlerAndSchemaOptions( + mcp.Tool{Name: "validation_override_tool", InputSchema: publicSchema}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, args input) (*mcp.CallToolResult, output, error) { + return nil, output(args), nil + }, + TypedSchemaOptions{ValidationInputSchema: validationSchema}, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.Contains(t, mustMarshalJSON(t, list.Tools[0].InputSchema), `"enum":["open"]`) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "validation_override_tool", + Arguments: map[string]any{"state": "legacy-value"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, `{"state":"legacy-value"}`, mustMarshalJSON(t, result.StructuredContent)) +} + +func TestValidationInputSchemaIsCachedAndDoesNotInjectDefaults(t *testing.T) { + type paginationInput struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + } + type paginationOutput struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + } + publicSchema := &jsonschema.Schema{} + require.NoError(t, json.Unmarshal([]byte(`{ + "type":"object", + "properties":{ + "page":{"type":"integer","minimum":1,"default":1}, + "per_page":{"type":"integer","minimum":1,"default":30} + } + }`), publicSchema)) + validationSchema := CloneSchemaWithoutDefaults(publicSchema) + minimum := float64(0) + validationSchema.Properties["page"].Minimum = &minimum + validationSchema.Properties["per_page"].Minimum = &minimum + + handlerCalls := 0 + newTool := func(name string) ServerTool { + return NewServerToolWithContextHandlerAndSchemaOptions( + mcp.Tool{Name: name, InputSchema: publicSchema}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, args paginationInput) (*mcp.CallToolResult, paginationOutput, error) { + handlerCalls++ + return nil, paginationOutput(args), nil + }, + TypedSchemaOptions{ValidationInputSchema: validationSchema}, + ) + } + tool := newTool("pagination_tool") + registration := tool.typedRegistration(&tool.Tool) + runtimeSchema := registration.modernRuntimeTool.InputSchema.(*jsonschema.Schema) + otherTool := newTool("other_pagination_tool") + otherRegistration := otherTool.typedRegistration(&otherTool.Tool) + assert.Same(t, runtimeSchema, otherRegistration.modernRuntimeTool.InputSchema, + "equal runtime schemas must reuse a process-cached immutable pointer") + assert.Contains(t, mustMarshalJSON(t, registration.modernTool.InputSchema), `"minimum":1`) + assert.Contains(t, mustMarshalJSON(t, registration.modernTool.InputSchema), `"default":30`) + assert.Contains(t, mustMarshalJSON(t, runtimeSchema), `"minimum":0`) + assert.NotContains(t, mustMarshalJSON(t, runtimeSchema), `"default"`) + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "pagination_tool", + Arguments: map[string]any{}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, `{"page":0,"per_page":0}`, mustMarshalJSON(t, result.StructuredContent), + "JSON Schema defaults are validation metadata and must not populate omitted query parameters") + assert.Equal(t, 1, handlerCalls) + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "pagination_tool", + Arguments: map[string]any{"page": -1}, + }) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Equal(t, 1, handlerCalls, "runtime validation must continue enforcing the relaxed schema's minimum") +} + +func TestTypedSchemaEnumsAndPreflightRunBeforeSDKValidation(t *testing.T) { + type input struct { + State string `json:"state"` + } + type output struct { + State string `json:"state"` + } + type preparedKey struct{} + handlerCalls := 0 + preflightCalls := 0 + tool := NewServerToolWithContextHandlerAndSchemaOptions( + mcp.Tool{Name: "preflight_schema_tool"}, + testToolsetMetadata("test"), + func(ctx context.Context, _ *mcp.CallToolRequest, args input) (*mcp.CallToolResult, output, error) { + handlerCalls++ + require.Equal(t, "prepared", ctx.Value(preparedKey{})) + return nil, output(args), nil + }, + TypedSchemaOptions{ + OutputEnums: []SchemaEnum{{Path: "state", Values: []string{"open", "closed"}}}, + Preflight: func(ctx context.Context, req *mcp.CallToolRequest) (context.Context, *mcp.CallToolResult, error) { + preflightCalls++ + if bytes.Contains(req.Params.Arguments, []byte(`"state":42`)) { + return ctx, &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "preflight rejected raw arguments"}}, + IsError: true, + }, nil + } + return context.WithValue(ctx, preparedKey{}, "prepared"), nil, nil + }, + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + + invalid, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "preflight_schema_tool", + Arguments: map[string]any{"state": 42}, + }) + require.NoError(t, err) + require.True(t, invalid.IsError) + require.Len(t, invalid.Content, 1) + assert.Equal(t, "preflight rejected raw arguments", invalid.Content[0].(*mcp.TextContent).Text) + assert.Nil(t, invalid.StructuredContent) + assert.Zero(t, handlerCalls) + + valid, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "preflight_schema_tool", + Arguments: map[string]any{"state": "open"}, + }) + require.NoError(t, err) + require.False(t, valid.IsError) + assert.JSONEq(t, `{"state":"open"}`, mustMarshalJSON(t, valid.StructuredContent)) + assert.Equal(t, 2, preflightCalls) + assert.Equal(t, 1, handlerCalls) + + _, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "preflight_schema_tool", + Arguments: map[string]any{"state": "unknown"}, + }) + require.ErrorContains(t, err, "validating tool output") + assert.Equal(t, 2, handlerCalls, "the SDK must reject output enum violations before returning structured success") +} + +func TestTypedPreflightRunsAfterAvailabilityAndAuthorization(t *testing.T) { + for _, blockedBy := range []string{"availability", "authorization"} { + t.Run(blockedBy, func(t *testing.T) { + preflightCalls := 0 + handlerCalls := 0 + tool := NewServerToolWithContextHandlerAndSchemaOptions( + mcp.Tool{Name: "guarded_preflight"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, typedTestInput) (*mcp.CallToolResult, typedTestOutput, error) { + handlerCalls++ + return nil, typedTestOutput{}, nil + }, + TypedSchemaOptions{ + Preflight: func(ctx context.Context, _ *mcp.CallToolRequest) (context.Context, *mcp.CallToolResult, error) { + preflightCalls++ + return ctx, &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "preflight ran"}}}, nil + }, + }, + ) + var middleware []ToolHandlerMiddleware + if blockedBy == "availability" { + tool.MinimumProtocolVersion = "2027-01-01" + } else { + middleware = append(middleware, func(_ mcp.ToolHandler) mcp.ToolHandler { + return func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "unauthorized"}}}, nil + } + }) + } + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1"}, nil) + tool.RegisterFunc(server, nil, middleware...) + session := connectTypedTestClient(t, server, "") + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Zero(t, preflightCalls) + assert.Zero(t, handlerCalls) + assert.NotContains(t, mustMarshalJSON(t, result), "preflight ran") + if blockedBy == "authorization" { + assert.Equal(t, "unauthorized", result.Content[0].(*mcp.TextContent).Text) + } + }) + } +} + +func TestTypedPreflightErrorsPreserveToolAndProtocolErrorSemantics(t *testing.T) { + for _, protocolError := range []bool{false, true} { + t.Run(fmt.Sprintf("protocol=%t", protocolError), func(t *testing.T) { + tool := NewServerToolWithContextHandlerAndSchemaOptions( + mcp.Tool{Name: "preflight_error"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, typedTestOutput, error) { + t.Fatal("preflight must short-circuit the handler") + return nil, typedTestOutput{}, nil + }, + TypedSchemaOptions{ + Preflight: func(ctx context.Context, _ *mcp.CallToolRequest) (context.Context, *mcp.CallToolResult, error) { + if protocolError { + return ctx, nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "protocol rejected"} + } + return ctx, nil, errors.New("preflight failed") + }, + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1"}, nil) + tool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name}) + if protocolError { + require.ErrorContains(t, err, "protocol rejected") + assert.Nil(t, result) + } else { + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, result.Content[0].(*mcp.TextContent).Text, "preflight failed") + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedInputNormalizerPreservesStrictSchemaAndLegacyValues(t *testing.T) { + type listIssuesInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + State string `json:"state"` + IssueNumber int64 `json:"issue_number"` + } + type listIssuesOutput struct { + State string `json:"state"` + IssueNumber int64 `json:"issue_number"` + } + + handlerCalls := 0 + var got listIssuesInput + tool := NewServerToolWithContextHandler( + mcp.Tool{ + Name: "list_issues", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string","minLength":1}, + "repo":{"type":"string","minLength":1}, + "state":{"type":"string","enum":["OPEN","CLOSED"]}, + "issue_number":{"type":"integer"} + }, + "required":["owner","repo"] + }`), + }, + testToolsetMetadata("issues"), + func(_ context.Context, _ *mcp.CallToolRequest, input listIssuesInput) (*mcp.CallToolResult, listIssuesOutput, error) { + handlerCalls++ + got = input + return nil, listIssuesOutput{State: input.State, IssueNumber: input.IssueNumber}, nil + }, + normalizeListIssuesWireInput, + ) + directResult, err := tool.Handler(nil)(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Name: "list_issues", + Arguments: json.RawMessage(`{"owner":"octo","repo":"hello","state":"open","issue_number":"42"}`), + }, + }) + require.NoError(t, err) + assert.Nil(t, directResult) + assert.Equal(t, "OPEN", got.State) + assert.Equal(t, int64(42), got.IssueNumber) + handlerCalls = 0 + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + var inputSchema map[string]any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, list.Tools[0].InputSchema)), &inputSchema)) + properties := inputSchema["properties"].(map[string]any) + assert.Equal(t, "integer", properties["issue_number"].(map[string]any)["type"]) + assert.Equal(t, []any{"OPEN", "CLOSED"}, properties["state"].(map[string]any)["enum"]) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "list_issues", + Arguments: map[string]any{ + "owner": "octo", + "repo": "hello", + "state": "open", + "issue_number": "42", + }, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, 1, handlerCalls) + assert.Equal(t, "OPEN", got.State) + assert.Equal(t, int64(42), got.IssueNumber) + assert.JSONEq(t, `{"state":"OPEN","issue_number":42}`, mustMarshalJSON(t, result.StructuredContent)) + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "list_issues", + Arguments: map[string]any{"owner": "octo", "repo": "hello", "state": "unsupported"}, + }) + require.NoError(t, err) + assert.True(t, result.IsError, "normalization must not bypass SDK enum validation") + assert.Equal(t, 1, handlerCalls) + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "list_issues", + Arguments: map[string]any{"owner": "octo", "repo": "hello", "issue_number": "not-a-number"}, + }) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Equal(t, 1, handlerCalls) +} + +func TestInvalidArgumentsPreservesToolInputErrorMessage(t *testing.T) { + result := invalidArgumentsResult(fmt.Errorf( + "normalize tool arguments: %w", + &ToolInputError{Message: "missing required parameter: method"}, + )) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + assert.Equal(t, "missing required parameter: method", result.Content[0].(*mcp.TextContent).Text) +} + +func TestTypedInputNormalizerReusesScopeNormalizedArguments(t *testing.T) { + normalizerCalls := 0 + tool := &mcp.Tool{Name: "normalized_scope_tool"} + registration := &typedToolRegistration{ + name: tool.Name, + modernTool: tool, + legacyTool: tool, + modernRuntimeTool: tool, + legacyRuntimeTool: tool, + hasTypedOutput: true, + inputNormalizer: func(_ json.RawMessage) (json.RawMessage, error) { + normalizerCalls++ + return json.RawMessage(`{"state":"CHANGED"}`), nil + }, + } + wrapped := typedOutputMiddleware(&typedToolRegistrationSet{byName: map[string]*typedToolRegistration{ + tool.Name: registration, + }})(func(_ context.Context, _ string, request mcp.Request) (mcp.Result, error) { + call := request.(*mcp.CallToolRequest) + assert.JSONEq(t, `{"state":"OPEN"}`, string(call.Params.Arguments)) + return &mcp.CallToolResult{}, nil + }) + ctx := ghcontext.WithMCPMethodInfo(context.Background(), &ghcontext.MCPMethodInfo{ + Method: MCPMethodToolsCall, + ItemName: tool.Name, + ArgumentsNormalized: true, + NormalizedArguments: json.RawMessage(`{"state":"OPEN"}`), + }) + result, err := wrapped(ctx, MCPMethodToolsCall, &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Name: tool.Name, + Arguments: json.RawMessage(`{"state":"open"}`), + }, + }) + require.NoError(t, err) + require.NotNil(t, result) + assert.Zero(t, normalizerCalls, "scope and typed adapters must not run a non-idempotent normalizer twice") +} + +func normalizeListIssuesWireInput(arguments json.RawMessage) (json.RawMessage, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(arguments, &fields); err != nil { + return nil, err + } + if rawState, ok := fields["state"]; ok { + var state string + if err := json.Unmarshal(rawState, &state); err != nil { + return nil, err + } + normalizedState, err := json.Marshal(strings.ToUpper(state)) + if err != nil { + return nil, err + } + fields["state"] = normalizedState + } + if rawID, ok := fields["issue_number"]; ok && len(rawID) > 0 && rawID[0] == '"' { + var id string + if err := json.Unmarshal(rawID, &id); err != nil { + return nil, err + } + parsedID, err := strconv.ParseInt(id, 10, 64) + if err != nil { + return nil, err + } + fields["issue_number"] = json.RawMessage(strconv.FormatInt(parsedID, 10)) + } + return json.Marshal(fields) +} + +func TestTypedInputNormalizerUsesLastRegisteredDefinition(t *testing.T) { + type input struct { + State string `json:"state"` + } + type output struct { + State string `json:"state"` + } + + first := NewServerToolWithContextHandler( + mcp.Tool{Name: "duplicate_tool"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, args input) (*mcp.CallToolResult, output, error) { + return nil, output{State: "first:" + args.State}, nil + }, + func(_ json.RawMessage) (json.RawMessage, error) { + return json.RawMessage(`{"state":"OPEN"}`), nil + }, + ) + last := NewServerToolWithContextHandler( + mcp.Tool{ + Name: "duplicate_tool", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{"state":{"type":"string","enum":["open"]}}, + "required":["state"] + }`), + }, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, args input) (*mcp.CallToolResult, output, error) { + return nil, output{State: "last:" + args.State}, nil + }, + ) + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + first.RegisterFunc(server, nil) + last.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "duplicate_tool", + Arguments: map[string]any{"state": "open"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, `{"state":"last:open"}`, mustMarshalJSON(t, result.StructuredContent)) +} + +func TestTypedToolMiddlewareShortCircuitAndHandlerErrors(t *testing.T) { + handlerCalls := 0 + shortCircuit := NewServerToolWithContextHandler( + mcp.Tool{Name: "short_circuit_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, typedTestOutput, error) { + handlerCalls++ + return nil, typedTestOutput{Query: "should not run"}, nil + }, + ) + handlerError := NewServerToolWithContextHandler( + mcp.Tool{Name: "handler_error_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, typedTestOutput, error) { + return nil, typedTestOutput{}, errors.New("typed handler failed") + }, + ) + type enumOutput struct { + Method string `json:"method"` + } + errorResult := NewServerToolWithContextHandler( + mcp.Tool{ + Name: "typed_error_result_tool", + OutputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": {Type: "string", Enum: []any{"list_projects"}}, + }, + Required: []string{"method"}, + }, + }, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, *enumOutput, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "project list failed"}}, + IsError: true, + }, nil, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + shortCircuit.RegisterFunc(server, nil, func(mcp.ToolHandler) mcp.ToolHandler { + return func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "authorization required"}}, + IsError: true, + }, nil + } + }) + handlerError.RegisterFunc(server, nil) + errorResult.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "short_circuit_tool"}) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1) + assert.Equal(t, "authorization required", result.Content[0].(*mcp.TextContent).Text) + assert.Zero(t, handlerCalls, "middleware short-circuit must not invoke the typed handler") + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{Name: "handler_error_tool"}) + require.NoError(t, err) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + assert.Equal(t, "typed handler failed", result.Content[0].(*mcp.TextContent).Text) + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_error_result_tool"}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1) + assert.Equal(t, "project list failed", result.Content[0].(*mcp.TextContent).Text) +} + +func TestTypedPointerOutputAndUntypedGenericCompatibility(t *testing.T) { + type pointerOutput struct { + Value *string `json:"value,omitempty"` + } + type pointerInput struct { + Include bool `json:"include"` + } + value := "present" + pointerTool := NewServerToolWithContextHandler( + mcp.Tool{Name: "pointer_tool"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, input pointerInput) (*mcp.CallToolResult, *pointerOutput, error) { + output := &pointerOutput{} + if input.Include { + output.Value = &value + } + return nil, output, nil + }, + ) + untypedCalls := 0 + untypedTool := NewServerToolWithContextHandler[map[string]any, any]( + mcp.Tool{ + Name: "untyped_generic_tool", + InputSchema: &jsonschema.Schema{Type: "object"}, + }, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, input map[string]any) (*mcp.CallToolResult, any, error) { + untypedCalls++ + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: input["extra"].(string)}}, + }, nil, nil + }, + ) + require.Nil(t, untypedTool.registerTyped, "any output must retain the raw handler path") + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + pointerTool.RegisterFunc(server, nil) + untypedTool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + toolsByName := make(map[string]*mcp.Tool, len(list.Tools)) + for _, tool := range list.Tools { + toolsByName[tool.Name] = tool + } + assert.NotNil(t, toolsByName["pointer_tool"].OutputSchema) + assert.Nil(t, toolsByName["untyped_generic_tool"].OutputSchema) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "pointer_tool", + Arguments: map[string]any{"include": true}, + }) + require.NoError(t, err) + assert.JSONEq(t, `{"value":"present"}`, mustMarshalJSON(t, result.StructuredContent)) + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "pointer_tool", + Arguments: map[string]any{"include": false}, + }) + require.NoError(t, err) + assert.JSONEq(t, `{}`, mustMarshalJSON(t, result.StructuredContent), "omitempty pointer fields must remain omitted") + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "untyped_generic_tool", + Arguments: map[string]any{"extra": "accepted"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + require.Len(t, result.Content, 1) + assert.Equal(t, "accepted", result.Content[0].(*mcp.TextContent).Text) + assert.Equal(t, 1, untypedCalls) +} + +func TestTypedToolOutputProtocolGatePreservesTextAndSharedTool(t *testing.T) { + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_array_tool"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, []string, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "legacy array text"}}, + }, []string{"one", "two"}, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + + modern := connectTypedTestClient(t, server, "") + legacy := connectTypedTestClient(t, server, "2025-11-25") + assert.Equal(t, ProtocolVersionMultiRoundTrip, modern.InitializeResult().ProtocolVersion) + assert.Equal(t, "2025-11-25", legacy.InitializeResult().ProtocolVersion) + + legacyList, err := legacy.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, legacyList.Tools, 1) + assert.Nil(t, legacyList.Tools[0].OutputSchema) + + modernList, err := modern.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, modernList.Tools, 1) + assert.NotNil(t, modernList.Tools[0].OutputSchema) + assert.JSONEq(t, `{"type":["null","array"],"items":{"type":"string"}}`, mustMarshalJSON(t, modernList.Tools[0].OutputSchema)) + + legacyResult, err := legacy.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_array_tool"}) + require.NoError(t, err) + assert.Nil(t, legacyResult.StructuredContent) + require.Len(t, legacyResult.Content, 1, "the SDK's array fallback must not duplicate the old text") + assert.Equal(t, "legacy array text", legacyResult.Content[0].(*mcp.TextContent).Text) + + modernResult, err := modern.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_array_tool"}) + require.NoError(t, err) + assert.JSONEq(t, `["one","two"]`, mustMarshalJSON(t, modernResult.StructuredContent)) + require.Len(t, modernResult.Content, 1) + assert.Equal(t, `["one","two"]`, modernResult.Content[0].(*mcp.TextContent).Text) + + legacyList, err = legacy.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, legacyList.Tools, 1) + assert.Nil(t, legacyList.Tools[0].OutputSchema, "modern discovery must not leak schema to an active legacy session") + + legacyResult, err = legacy.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_array_tool"}) + require.NoError(t, err) + assert.Nil(t, legacyResult.StructuredContent) + require.Len(t, legacyResult.Content, 1) + assert.Equal(t, "legacy array text", legacyResult.Content[0].(*mcp.TextContent).Text) + + modernResult, err = modern.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_array_tool"}) + require.NoError(t, err) + assert.JSONEq(t, `["one","two"]`, mustMarshalJSON(t, modernResult.StructuredContent)) + require.Len(t, modernResult.Content, 1) + assert.Equal(t, `["one","two"]`, modernResult.Content[0].(*mcp.TextContent).Text) + + type concurrentCall struct { + list *mcp.ListToolsResult + result *mcp.CallToolResult + err error + } + type clientCalls struct { + modern bool + calls []concurrentCall + } + const iterations = 5 + start := make(chan struct{}) + calls := make(chan clientCalls, 2) + var workers sync.WaitGroup + runClient := func(session *mcp.ClientSession, isModern bool) { + defer workers.Done() + <-start + run := clientCalls{modern: isModern, calls: make([]concurrentCall, 0, iterations)} + for range iterations { + list, listErr := session.ListTools(context.Background(), nil) + if listErr != nil { + run.calls = append(run.calls, concurrentCall{err: listErr}) + break + } + result, callErr := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_array_tool"}) + run.calls = append(run.calls, concurrentCall{list: list, result: result, err: callErr}) + if callErr != nil { + break + } + } + calls <- run + } + workers.Add(2) + go runClient(modern, true) + go runClient(legacy, false) + close(start) + go func() { + workers.Wait() + close(calls) + }() + for run := range calls { + require.Len(t, run.calls, iterations) + for _, call := range run.calls { + require.NoError(t, call.err) + require.Len(t, call.list.Tools, 1) + if run.modern { + assert.NotNil(t, call.list.Tools[0].OutputSchema) + assert.JSONEq(t, `["one","two"]`, mustMarshalJSON(t, call.result.StructuredContent)) + require.Len(t, call.result.Content, 1) + assert.Equal(t, `["one","two"]`, call.result.Content[0].(*mcp.TextContent).Text) + } else { + assert.Nil(t, call.list.Tools[0].OutputSchema) + assert.Nil(t, call.result.StructuredContent) + require.Len(t, call.result.Content, 1) + assert.Equal(t, "legacy array text", call.result.Content[0].(*mcp.TextContent).Text) + } + } + } + + require.Nil(t, tool.Tool.OutputSchema, "registration must not mutate the shared definition") +} + +func TestTypedScalarOutputProtocolGatePreservesHandlerText(t *testing.T) { + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_scalar_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, string, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "custom legacy text"}}, + }, "structured scalar", nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + legacy := connectTypedTestClient(t, server, "2025-11-25") + + list, err := legacy.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.Nil(t, list.Tools[0].OutputSchema) + + result, err := legacy.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_scalar_tool"}) + require.NoError(t, err) + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1, "SDK scalar fallback must not replace or duplicate handler text") + assert.Equal(t, "custom legacy text", result.Content[0].(*mcp.TextContent).Text) +} + +func TestTypedLegacyOutputGateRemovesFallbackWithEmptyHandlerContent(t *testing.T) { + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_empty_content_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, []string, error) { + return &mcp.CallToolResult{Content: []mcp.Content{}}, []string{"structured"}, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + legacy := connectTypedTestClient(t, server, "2025-11-25") + + result, err := legacy.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_empty_content_tool"}) + require.NoError(t, err) + assert.Nil(t, result.StructuredContent) + assert.Empty(t, result.Content, "the SDK fallback is not part of the legacy handler result") +} + +func TestTypedOutputMiddlewareShortCircuitPreservesErrorResult(t *testing.T) { + type output struct { + Required string `json:"required"` + } + handlerCalls := 0 + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_short_circuit_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, output, error) { + handlerCalls++ + return nil, output{Required: "unexpected"}, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil, func(_ mcp.ToolHandler) mcp.ToolHandler { + return func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "blocked"}}, + IsError: true, + }, nil + } + }) + session := connectTypedTestClient(t, server, "") + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_short_circuit_tool"}) + require.NoError(t, err) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + assert.Equal(t, "blocked", result.Content[0].(*mcp.TextContent).Text) + assert.Nil(t, result.StructuredContent) + assert.Zero(t, handlerCalls) +} + +func TestTypedOutputGateUsesCurrentRequestVersionNotInitializeRequest(t *testing.T) { + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_initialize_version_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, []string, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "legacy initialize text"}}, + }, []string{"one"}, nil + }, + ) + server := mcp.NewServer( + &mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, + &mcp.ServerOptions{SupportedProtocolVersions: []string{"2025-11-25"}}, + ) + tool.RegisterFunc(server, nil) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + transport := protocolVersionTransport{ + Transport: clientTransport, + protocolVersion: ProtocolVersionMultiRoundTrip, + } + session, err := client.Connect(context.Background(), transport, &mcp.ClientSessionOptions{ + ProtocolVersion: "2025-11-25", + }) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + require.Equal(t, "2025-11-25", session.InitializeResult().ProtocolVersion) + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.Nil(t, list.Tools[0].OutputSchema) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_initialize_version_tool"}) + require.NoError(t, err) + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1) + assert.Equal(t, "legacy initialize text", result.Content[0].(*mcp.TextContent).Text) +} + +func TestTypedNullableOutputPreservesProtocolSemantics(t *testing.T) { + type output struct { + Value *string `json:"value"` + } + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_nullable_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, output, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "legacy null text"}}, + }, output{}, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + modern := connectTypedTestClient(t, server, "") + legacy := connectTypedTestClient(t, server, "2025-11-25") + + modernResult, err := modern.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_nullable_tool"}) + require.NoError(t, err) + assert.JSONEq(t, `{"value":null}`, mustMarshalJSON(t, modernResult.StructuredContent)) + require.Len(t, modernResult.Content, 1) + assert.JSONEq(t, `{"value":null}`, modernResult.Content[0].(*mcp.TextContent).Text) + + legacyResult, err := legacy.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_nullable_tool"}) + require.NoError(t, err) + assert.Nil(t, legacyResult.StructuredContent) + require.Len(t, legacyResult.Content, 1) + assert.Equal(t, "legacy null text", legacyResult.Content[0].(*mcp.TextContent).Text) +} + +func TestTypedToolSupportsExplicitRootUnionOutputSchema(t *testing.T) { + tool := NewServerToolWithContextHandler( + mcp.Tool{ + Name: "union_output_tool", + OutputSchema: json.RawMessage(`{ + "oneOf":[ + {"type":"string"}, + {"type":"array","items":{"type":"string"}} + ] + }`), + }, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, json.RawMessage, error) { + return nil, json.RawMessage(`["one","two"]`), nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.Contains(t, mustMarshalJSON(t, list.Tools[0].OutputSchema), `"oneOf"`) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "union_output_tool"}) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, `["one","two"]`, mustMarshalJSON(t, result.StructuredContent)) +} + +func TestTypedOutputProtocolGateUsesStatelessRequestVersion(t *testing.T) { + inputSchema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string", Extra: map[string]any{"x-mcp-header": "owner"}}, + }, + } + modernTool := &mcp.Tool{ + Name: "typed_tool", + InputSchema: inputSchema, + OutputSchema: json.RawMessage(`{"type":"object"}`), + } + legacyTool := &mcp.Tool{Name: "typed_tool", InputSchema: inputSchema} + registration := &typedToolRegistration{ + name: "typed_tool", + modernTool: modernTool, + legacyTool: legacyTool, + modernRuntimeTool: modernTool, + legacyRuntimeTool: legacyTool, + hasTypedOutput: true, + } + wrapped := typedOutputMiddleware(&typedToolRegistrationSet{byName: map[string]*typedToolRegistration{ + "typed_tool": registration, + }})(func(_ context.Context, method string, _ mcp.Request) (mcp.Result, error) { + require.Equal(t, MCPMethodToolsList, method) + return &mcp.ListToolsResult{Tools: []*mcp.Tool{modernTool}}, nil + }) + + for _, tc := range []struct { + name string + protocolVersion string + wantTool *mcp.Tool + }{ + {name: "stateless modern", protocolVersion: ProtocolVersionMultiRoundTrip, wantTool: modernTool}, + {name: "stateless legacy", protocolVersion: "2025-11-25", wantTool: legacyTool}, + {name: "unknown version defaults to legacy", protocolVersion: "2027-01-01", wantTool: legacyTool}, + {name: "absent version defaults to legacy", wantTool: legacyTool}, + } { + t.Run(tc.name, func(t *testing.T) { + meta := mcp.Meta{} + if tc.protocolVersion != "" { + meta[mcp.MetaKeyProtocolVersion] = tc.protocolVersion + } + listResult, err := wrapped(context.Background(), MCPMethodToolsList, &mcp.ListToolsRequest{ + Params: &mcp.ListToolsParams{Meta: meta}, + }) + require.NoError(t, err) + list := listResult.(*mcp.ListToolsResult) + require.Len(t, list.Tools, 1) + assert.Same(t, tc.wantTool, list.Tools[0]) + assert.Contains(t, mustMarshalJSON(t, list.Tools[0].InputSchema), `"x-mcp-header":"owner"`, + "legacy and unknown-version variants must retain routing metadata") + }) + } + + inputRequired := &mcp.CallToolResult{ + StructuredContent: map[string]any{"awaiting": true}, + InputRequests: mcp.InputRequestMap{"input": &mcp.ElicitParams{Mode: "form"}}, + } + rawMiddleware := typedOutputMiddleware(nil)(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return inputRequired, nil + }) + result, err := rawMiddleware(context.Background(), MCPMethodToolsCall, &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{Name: "raw_tool"}, + }) + require.NoError(t, err) + assert.Same(t, inputRequired, result, "non-typed multi-round-trip structured content must pass through") + + typedRegistration := &typedToolRegistration{ + name: "typed_tool", + modernTool: modernTool, + legacyTool: legacyTool, + modernRuntimeTool: modernTool, + legacyRuntimeTool: legacyTool, + hasTypedOutput: true, + } + typedMiddleware := typedOutputMiddleware(&typedToolRegistrationSet{byName: map[string]*typedToolRegistration{ + "typed_tool": typedRegistration, + }})(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return inputRequired, nil + }) + for _, tc := range []struct { + name string + protocolVersion string + wantStructured bool + }{ + {name: "modern", protocolVersion: ProtocolVersionMultiRoundTrip, wantStructured: true}, + {name: "legacy", protocolVersion: "2025-11-25"}, + } { + t.Run("typed awaiting form "+tc.name, func(t *testing.T) { + meta := mcp.Meta{} + if tc.protocolVersion != "" { + meta[mcp.MetaKeyProtocolVersion] = tc.protocolVersion + } + result, err := typedMiddleware(context.Background(), MCPMethodToolsCall, &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{Name: "typed_tool", Meta: meta}, + }) + require.NoError(t, err) + callResult := result.(*mcp.CallToolResult) + if tc.wantStructured { + assert.Same(t, inputRequired, callResult) + assert.Equal(t, map[string]any{"awaiting": true}, callResult.StructuredContent) + } else { + assert.NotSame(t, inputRequired, callResult) + assert.Nil(t, callResult.StructuredContent, "legacy typed tools must not expose structured output") + } + assert.Equal(t, inputRequired.InputRequests, callResult.InputRequests) + }) + } +} + +func TestTypedOutputGateLeavesRawToolResultsAlone(t *testing.T) { + typedOverride := NewServerToolWithContextHandler( + mcp.Tool{Name: "raw_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, map[string]any, error) { + return nil, map[string]any{"typed": true}, nil + }, + ) + rawTool := NewServerTool( + mcp.Tool{ + Name: "raw_tool", + InputSchema: &jsonschema.Schema{Type: "object"}, + OutputSchema: json.RawMessage(`{"type":"object"}`), + }, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + StructuredContent: map[string]any{"state": "awaiting"}, + }, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + typedOverride.RegisterFunc(server, nil) + rawTool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "2025-11-25") + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.JSONEq(t, `{"type":"object"}`, mustMarshalJSON(t, list.Tools[0].OutputSchema), "raw tool schemas must remain unchanged") + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "raw_tool"}) + require.NoError(t, err) + require.NotNil(t, result.StructuredContent, "raw structured content without typed output must remain untouched") +} + +func connectTypedTestClient(t *testing.T, server *mcp.Server, protocolVersion string) *mcp.ClientSession { + t.Helper() + serverTransport, clientTransport := mcp.NewInMemoryTransports() + if protocolVersion != "" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + if method == "server/discover" { + return nil, errors.New("legacy client uses initialize") + } + return next(ctx, method, request) + } + }) + } + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, &mcp.ClientOptions{}) + var transport mcp.Transport = clientTransport + if protocolVersion != "" { + transport = protocolVersionTransport{Transport: clientTransport, protocolVersion: protocolVersion} + } + clientSession, err := client.Connect(context.Background(), transport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = clientSession.Close() }) + if protocolVersion != "" { + require.Equal(t, protocolVersion, clientSession.InitializeResult().ProtocolVersion) + } + return clientSession +} + +func mustMarshalJSON(t *testing.T, value any) string { + t.Helper() + encoded, err := json.Marshal(value) + require.NoError(t, err) + return string(encoded) +} + +type protocolVersionTransport struct { + mcp.Transport + protocolVersion string +} + +func (t protocolVersionTransport) Connect(ctx context.Context) (mcp.Connection, error) { + connection, err := t.Transport.Connect(ctx) + if err != nil { + return nil, err + } + return protocolVersionConnection{Connection: connection, protocolVersion: t.protocolVersion}, nil +} + +type protocolVersionConnection struct { + mcp.Connection + protocolVersion string +} + +func (c protocolVersionConnection) Write(ctx context.Context, message jsonrpc.Message) error { + request, ok := message.(*jsonrpc.Request) + if !ok || request.Method != "initialize" { + return c.Connection.Write(ctx, message) + } + var params map[string]json.RawMessage + if err := json.Unmarshal(request.Params, ¶ms); err != nil { + return err + } + version, err := json.Marshal(c.protocolVersion) + if err != nil { + return err + } + params["protocolVersion"] = version + encoded, err := json.Marshal(params) + if err != nil { + return err + } + requestCopy := *request + requestCopy.Params = encoded + return c.Connection.Write(ctx, &requestCopy) +} diff --git a/pkg/inventory/typed_schema.go b/pkg/inventory/typed_schema.go new file mode 100644 index 0000000000..5c1f78d558 --- /dev/null +++ b/pkg/inventory/typed_schema.go @@ -0,0 +1,528 @@ +package inventory + +import ( + "encoding/json" + "fmt" + "maps" + "reflect" + "slices" + "sort" + "strings" + "sync" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ProtocolEra selects the wire-compatible registration variant. +type ProtocolEra uint8 + +const ( + // ProtocolEraDynamic selects a variant at request time. + ProtocolEraDynamic ProtocolEra = iota + // ProtocolEraLegacy registers without an output schema or generated output. + ProtocolEraLegacy + // ProtocolEraModern registers the 2026-07-28 output-schema variant. + ProtocolEraModern +) + +// ProtocolEraForVersion selects modern behavior for SDK-supported protocol +// versions from 2026-07-28 onward. Unknown versions remain legacy. +func ProtocolEraForVersion(version string) ProtocolEra { + return protocolEraForSupportedVersion(version, mcp.SupportedProtocolVersions()) +} + +func protocolEraForSupportedVersion(version string, supported []string) ProtocolEra { + if _, err := time.Parse(time.DateOnly, version); err != nil { + return ProtocolEraLegacy + } + if version < ProtocolVersionMultiRoundTrip || !slices.Contains(supported, version) { + return ProtocolEraLegacy + } + return ProtocolEraModern +} + +// SchemaEnum adds an enum to a property of an inferred schema. Path is a +// dot-separated JSON Schema path; use "items" to descend into array items. +type SchemaEnum struct { + Path string + Values []string +} + +// EnumSchema returns a string schema constrained to values. +func EnumSchema(values ...string) *jsonschema.Schema { + enum := make([]any, len(values)) + for i, value := range values { + enum[i] = value + } + return &jsonschema.Schema{Type: "string", Enum: enum} +} + +// WithEnum clones schema and adds a string enum at the requested schema path. +// Array item schemas are addressed with the "items" path segment. +func WithEnum(schema *jsonschema.Schema, path string, values ...string) (*jsonschema.Schema, error) { + if schema == nil { + return nil, fmt.Errorf("schema is nil") + } + segments := strings.Split(path, ".") + if path == "" { + return nil, fmt.Errorf("schema path is empty") + } + clonedSchema := CloneSchema(schema) + current := clonedSchema + for _, segment := range segments { + switch segment { + case "items": + if current.Items == nil { + return nil, fmt.Errorf("schema path %q has no items schema", path) + } + current = current.Items + default: + if current.Properties == nil || current.Properties[segment] == nil { + return nil, fmt.Errorf("schema path %q has no property %q", path, segment) + } + current = current.Properties[segment] + } + } + current.Type = "string" + current.Types = nil + current.Enum = make([]any, len(values)) + for i, value := range values { + current.Enum[i] = value + } + return clonedSchema, nil +} + +type inferredSchemaKey struct { + goType reflect.Type + options string + enums string + inputSchema bool +} + +type inferredSchemaEntry struct { + once sync.Once + schema *jsonschema.Schema + err error +} + +var inferredSchemaCache sync.Map +var explicitSchemaCache sync.Map +var ownedSchemaPointers sync.Map +var annotatedSchemaCache sync.Map +var headerInputSchemaCache sync.Map +var defaultObjectInputSchemaEntry inferredSchemaEntry + +type resolvedInputSchemaEntry struct { + once sync.Once + resolved *jsonschema.Resolved + err error +} + +var resolvedInputSchemaCache sync.Map + +func cachedInputSchema(value any) (*jsonschema.Schema, error) { + schema, ok := value.(*jsonschema.Schema) + if !ok { + encoded, err := json.Marshal(value) + if err != nil { + return nil, err + } + schema = new(jsonschema.Schema) + if err := json.Unmarshal(encoded, schema); err != nil { + return nil, err + } + } + schema, err := CachedSchema(schema) + if err != nil { + return nil, err + } + if schema.Type != "object" { + return nil, fmt.Errorf("input schema must have type \"object\"") + } + return schema, nil +} + +func cachedResolvedInputSchema(value any) (*jsonschema.Resolved, error) { + schema, err := cachedInputSchema(value) + if err != nil { + return nil, err + } + entryValue, _ := resolvedInputSchemaCache.LoadOrStore(schema, &resolvedInputSchemaEntry{}) + entry := entryValue.(*resolvedInputSchemaEntry) + entry.once.Do(func() { + entry.resolved, entry.err = schema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) + }) + return entry.resolved, entry.err +} + +func cachedObjectInputSchema() (*jsonschema.Schema, error) { + defaultObjectInputSchemaEntry.once.Do(func() { + defaultObjectInputSchemaEntry.schema, defaultObjectInputSchemaEntry.err = CachedSchema(&jsonschema.Schema{Type: "object"}) + }) + return defaultObjectInputSchemaEntry.schema, defaultObjectInputSchemaEntry.err +} + +// Keep header bindings visible to the SDK's HTTP transport without restoring +// required fields, defaults, or other body constraints ahead of tool guards. +func cachedHeaderInputSchema(value any) (*jsonschema.Schema, error) { + schema, err := cachedInputSchema(value) + if err != nil { + return nil, err + } + entryValue, _ := headerInputSchemaCache.LoadOrStore(schema, &inferredSchemaEntry{}) + entry := entryValue.(*inferredSchemaEntry) + entry.once.Do(func() { + headerSchema := headerPropertySchema(schema) + headerSchema.Type = "object" + entry.schema, entry.err = CachedSchema(headerSchema) + }) + return entry.schema, entry.err +} + +func headerPropertySchema(schema *jsonschema.Schema) *jsonschema.Schema { + result := &jsonschema.Schema{} + if annotation, ok := schema.Extra["x-mcp-header"]; ok { + // The SDK requires a primitive type when validating header annotations. + result.Type = schema.Type + result.Extra = map[string]any{"x-mcp-header": annotation} + } + for name, property := range schema.Properties { + if property == nil { + continue + } + child := headerPropertySchema(property) + if len(child.Extra) == 0 && len(child.Properties) == 0 { + continue + } + if result.Properties == nil { + result.Properties = make(map[string]*jsonschema.Schema) + } + result.Properties[name] = child + } + return result +} + +// CloneSchemaWithoutDefaults returns a deep schema copy with default keywords +// removed. The MCP SDK applies input-schema defaults before decoding arguments, +// so use this for runtime validation schemas when omitted arguments must stay +// omitted. The original advertised schema is unchanged. +func CloneSchemaWithoutDefaults(schema *jsonschema.Schema) *jsonschema.Schema { + clonedSchema := CloneSchema(schema) + removeSchemaDefaults(clonedSchema) + return clonedSchema +} + +// CloneSchema deep-copies schema nodes, numeric bounds, and mutable metadata. +func CloneSchema(schema *jsonschema.Schema) *jsonschema.Schema { + clonedSchema := schema.CloneSchemas() + cloneSchemaMetadata(clonedSchema) + return clonedSchema +} + +type schemaValueVisit struct { + typeOf reflect.Type + ptr uintptr +} + +func cloneSchemaMetadata(schema *jsonschema.Schema) { + if schema == nil { + return + } + schema.MultipleOf = cloneSchemaPointer(schema.MultipleOf) + schema.Minimum = cloneSchemaPointer(schema.Minimum) + schema.Maximum = cloneSchemaPointer(schema.Maximum) + schema.ExclusiveMinimum = cloneSchemaPointer(schema.ExclusiveMinimum) + schema.ExclusiveMaximum = cloneSchemaPointer(schema.ExclusiveMaximum) + schema.MinLength = cloneSchemaPointer(schema.MinLength) + schema.MaxLength = cloneSchemaPointer(schema.MaxLength) + schema.MinItems = cloneSchemaPointer(schema.MinItems) + schema.MaxItems = cloneSchemaPointer(schema.MaxItems) + schema.MinContains = cloneSchemaPointer(schema.MinContains) + schema.MaxContains = cloneSchemaPointer(schema.MaxContains) + schema.MinProperties = cloneSchemaPointer(schema.MinProperties) + schema.MaxProperties = cloneSchemaPointer(schema.MaxProperties) + schema.Types = slices.Clone(schema.Types) + schema.Enum = cloneSchemaValue(reflect.ValueOf(schema.Enum), make(map[schemaValueVisit]reflect.Value)).Interface().([]any) + schema.Default = slices.Clone(schema.Default) + if schema.Const != nil { + value := any(nil) + if *schema.Const != nil { + value = cloneSchemaValue(reflect.ValueOf(*schema.Const), make(map[schemaValueVisit]reflect.Value)).Interface() + } + schema.Const = &value + } + schema.Examples = cloneSchemaValue(reflect.ValueOf(schema.Examples), make(map[schemaValueVisit]reflect.Value)).Interface().([]any) + schema.Required = slices.Clone(schema.Required) + if schema.Vocabulary != nil { + schema.Vocabulary = maps.Clone(schema.Vocabulary) + } + if schema.DependencyStrings != nil { + dependencies := make(map[string][]string, len(schema.DependencyStrings)) + for key, values := range schema.DependencyStrings { + dependencies[key] = slices.Clone(values) + } + schema.DependencyStrings = dependencies + } + if schema.DependentRequired != nil { + dependencies := make(map[string][]string, len(schema.DependentRequired)) + for key, values := range schema.DependentRequired { + dependencies[key] = slices.Clone(values) + } + schema.DependentRequired = dependencies + } + schema.PropertyOrder = slices.Clone(schema.PropertyOrder) + if schema.Extra != nil { + schema.Extra = cloneSchemaValue(reflect.ValueOf(schema.Extra), make(map[schemaValueVisit]reflect.Value)).Interface().(map[string]any) + } + for _, child := range schemaChildren(schema) { + cloneSchemaMetadata(child) + } +} + +func cloneSchemaPointer[T int | float64](value *T) *T { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +// cloneSchemaValue preserves the concrete metadata types while detaching +// nested mutable collections from schemas retained by callers. +func cloneSchemaValue(value reflect.Value, visited map[schemaValueVisit]reflect.Value) reflect.Value { + if !value.IsValid() { + return value + } + switch value.Kind() { + case reflect.Interface: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + cloned := cloneSchemaValue(value.Elem(), visited) + result := reflect.New(value.Type()).Elem() + result.Set(cloned) + return result + case reflect.Pointer, reflect.Map, reflect.Slice: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + visit := schemaValueVisit{typeOf: value.Type(), ptr: value.Pointer()} + if cloned, ok := visited[visit]; ok { + return cloned + } + switch value.Kind() { + case reflect.Pointer: + result := reflect.New(value.Type().Elem()) + visited[visit] = result + result.Elem().Set(cloneSchemaValue(value.Elem(), visited)) + return result + case reflect.Map: + result := reflect.MakeMapWithSize(value.Type(), value.Len()) + visited[visit] = result + iter := value.MapRange() + for iter.Next() { + result.SetMapIndex(iter.Key(), cloneSchemaValue(iter.Value(), visited)) + } + return result + default: + result := reflect.MakeSlice(value.Type(), value.Len(), value.Len()) + visited[visit] = result + for i := range value.Len() { + result.Index(i).Set(cloneSchemaValue(value.Index(i), visited)) + } + return result + } + case reflect.Array: + result := reflect.New(value.Type()).Elem() + for i := range value.Len() { + result.Index(i).Set(cloneSchemaValue(value.Index(i), visited)) + } + return result + case reflect.Struct: + if !value.CanInterface() { + return value + } + result := reflect.New(value.Type()).Elem() + result.Set(value) + for i := range value.NumField() { + if value.Type().Field(i).IsExported() { + result.Field(i).Set(cloneSchemaValue(value.Field(i), visited)) + } + } + return result + default: + return value + } +} + +func schemaChildren(schema *jsonschema.Schema) []*jsonschema.Schema { + children := []*jsonschema.Schema{ + schema.Items, + schema.AdditionalItems, + schema.Contains, + schema.UnevaluatedItems, + schema.AdditionalProperties, + schema.PropertyNames, + schema.UnevaluatedProperties, + schema.Not, + schema.If, + schema.Then, + schema.Else, + schema.ContentSchema, + } + children = append(children, schema.PrefixItems...) + children = append(children, schema.ItemsArray...) + children = append(children, schema.AllOf...) + children = append(children, schema.AnyOf...) + children = append(children, schema.OneOf...) + for _, schemas := range []map[string]*jsonschema.Schema{ + schema.Defs, + schema.Definitions, + schema.Properties, + schema.PatternProperties, + schema.DependentSchemas, + schema.DependencySchemas, + } { + for _, child := range schemas { + children = append(children, child) + } + } + return children +} + +func removeSchemaDefaults(schema *jsonschema.Schema) { + if schema == nil { + return + } + schema.Default = nil + for _, child := range schemaChildren(schema) { + removeSchemaDefaults(child) + } +} + +// CachedSchema clones an explicit schema once per process and returns the +// immutable shared pointer. It is useful for schemas prepared by tool +// definitions that are reconstructed for request-scoped servers. +func CachedSchema(schema *jsonschema.Schema) (*jsonschema.Schema, error) { + if schema == nil { + return nil, nil + } + if _, owned := ownedSchemaPointers.Load(schema); owned { + return schema, nil + } + encoded, err := json.Marshal(schema) + if err != nil { + return nil, fmt.Errorf("marshal explicit schema: %w", err) + } + key := string(encoded) + if cached, ok := explicitSchemaCache.Load(key); ok { + return cached.(*jsonschema.Schema), nil + } + clonedSchema := CloneSchema(schema) + cached, _ := explicitSchemaCache.LoadOrStore(key, clonedSchema) + result := cached.(*jsonschema.Schema) + ownedSchemaPointers.Store(result, struct{}{}) + return result, nil +} + +// CachedSchemaFor infers a schema once per process for the Go type and +// immutable inference options. The returned schema is shared and must not be +// mutated. Enum overrides are applied while constructing the cached value. +func CachedSchemaFor[T any](options *jsonschema.ForOptions, enums ...SchemaEnum) (*jsonschema.Schema, error) { + return cachedSchemaFor(reflect.TypeFor[T](), options, enums, false) +} + +// CachedInputSchemaFor is like CachedSchemaFor and adds the standard owner/repo +// routing annotations before caching the immutable schema. +func CachedInputSchemaFor[T any](options *jsonschema.ForOptions, enums ...SchemaEnum) (*jsonschema.Schema, error) { + return cachedSchemaFor(reflect.TypeFor[T](), options, enums, true) +} + +func cachedSchemaFor(goType reflect.Type, options *jsonschema.ForOptions, enums []SchemaEnum, inputSchema bool) (*jsonschema.Schema, error) { + // Match SDK input inference without removing nullable output semantics. + if inputSchema && goType.Kind() == reflect.Pointer { + goType = goType.Elem() + } + optionsKey, err := schemaOptionsKey(options) + if err != nil { + return nil, err + } + enumKey, err := json.Marshal(enums) + if err != nil { + return nil, fmt.Errorf("marshal schema enum overrides: %w", err) + } + key := inferredSchemaKey{ + goType: goType, + options: optionsKey, + enums: string(enumKey), + inputSchema: inputSchema, + } + entryValue, _ := inferredSchemaCache.LoadOrStore(key, &inferredSchemaEntry{}) + entry := entryValue.(*inferredSchemaEntry) + entry.once.Do(func() { + entry.schema, entry.err = jsonschema.ForType(goType, options) + if entry.err != nil { + return + } + for _, override := range enums { + entry.schema, entry.err = WithEnum(entry.schema, override.Path, override.Values...) + if entry.err != nil { + return + } + } + if inputSchema { + tool := mcp.Tool{InputSchema: entry.schema} + AnnotateHeaderParams(&tool) + entry.schema = tool.InputSchema.(*jsonschema.Schema) + } + }) + return entry.schema, entry.err +} + +func schemaOptionsKey(options *jsonschema.ForOptions) (string, error) { + if options == nil { + return "", nil + } + type schemaEntry struct { + Type string `json:"type"` + Schema json.RawMessage `json:"schema"` + } + types := make([]reflect.Type, 0, len(options.TypeSchemas)) + for goType := range options.TypeSchemas { + types = append(types, goType) + } + sort.Slice(types, func(i, j int) bool { + return schemaTypeKey(types[i]) < schemaTypeKey(types[j]) + }) + entries := make([]schemaEntry, 0, len(types)) + for _, goType := range types { + encoded, err := json.Marshal(options.TypeSchemas[goType]) + if err != nil { + return "", fmt.Errorf("marshal schema override for %s: %w", goType, err) + } + entries = append(entries, schemaEntry{ + Type: schemaTypeKey(goType), + Schema: encoded, + }) + } + encoded, err := json.Marshal(struct { + IgnoreInvalidTypes bool `json:"ignoreInvalidTypes"` + TypeSchemas []schemaEntry `json:"typeSchemas"` + }{ + IgnoreInvalidTypes: options.IgnoreInvalidTypes, + TypeSchemas: entries, + }) + if err != nil { + return "", fmt.Errorf("marshal schema inference options: %w", err) + } + return string(encoded), nil +} + +func schemaTypeKey(goType reflect.Type) string { + if goType.Name() != "" { + return goType.PkgPath() + "." + goType.Name() + } + return goType.String() +} diff --git a/pkg/lockdown/lockdown.go b/pkg/lockdown/lockdown.go index 8625073116..1e47890852 100644 --- a/pkg/lockdown/lockdown.go +++ b/pkg/lockdown/lockdown.go @@ -11,7 +11,7 @@ import ( "sync" "time" - "github.com/google/go-github/v89/github" + "github.com/google/go-github/v92/github" "github.com/muesli/cache2go" "github.com/shurcooL/githubv4" ) diff --git a/pkg/lockdown/lockdown_test.go b/pkg/lockdown/lockdown_test.go index 6254533682..4b3a984ff8 100644 --- a/pkg/lockdown/lockdown_test.go +++ b/pkg/lockdown/lockdown_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/githubv4mock" - gogithub "github.com/google/go-github/v89/github" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + gogithub "github.com/google/go-github/v92/github" "github.com/muesli/cache2go" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/require" @@ -89,7 +89,7 @@ func newMockGQLClient(viewerLogin string, isPrivate bool) (*githubv4.Client, *co 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)} + resp := gogithub.RepositoryPermissionLevel{Permission: new(permission)} w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) diff --git a/pkg/observability/observability.go b/pkg/observability/observability.go index 3741b05c75..72385ba47a 100644 --- a/pkg/observability/observability.go +++ b/pkg/observability/observability.go @@ -5,7 +5,7 @@ import ( "errors" "log/slog" - "github.com/github/github-mcp-server/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" ) // Exporters bundles observability primitives (logger + metrics) for dependency injection. diff --git a/pkg/observability/observability_test.go b/pkg/observability/observability_test.go index c8949fdbd4..7516c89f37 100644 --- a/pkg/observability/observability_test.go +++ b/pkg/observability/observability_test.go @@ -5,7 +5,7 @@ import ( "log/slog" "testing" - "github.com/github/github-mcp-server/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/octicons/icons/law-dark.png b/pkg/octicons/icons/law-dark.png new file mode 100644 index 0000000000..3c71275748 Binary files /dev/null and b/pkg/octicons/icons/law-dark.png differ diff --git a/pkg/octicons/icons/law-light.png b/pkg/octicons/icons/law-light.png new file mode 100644 index 0000000000..3121c5b654 Binary files /dev/null and b/pkg/octicons/icons/law-light.png differ diff --git a/pkg/octicons/icons_data_uris.txt b/pkg/octicons/icons_data_uris.txt index 1083af68b8..b5899f7589 100644 --- a/pkg/octicons/icons_data_uris.txt +++ b/pkg/octicons/icons_data_uris.txt @@ -30,6 +30,8 @@ git-pull-request-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYA git-pull-request-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC issue-opened-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII= issue-opened-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC +law-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAB20lEQVRIieWVz4uNYRTHP8+9t9ya7qiZNUWpmQ0LkaLGRcOOQpaUWbBipxE3439gacNiyg4LG12xRoNSmqZJidkxKTPl+lg4V++89+eblXxX7znne77fc56etwf+K6i31VtFeioFPSYL8gcbqMeAXRFujdyViBdSSo+LmraFx9V5B2NeHR8kNqWud2leU2fVSvCaajO+K1Fb69K3rk5lDcbUhnonCO/Va+pEbpA/BpncpHo9egyNhjrWbZObQVpVN3epdxhEflT9Gr1z2VopQ6oCF4C3QA043/dcN2IGGI3ei6HVMcVMTHBQfaout8++3wZqWV2KWj00OodTF9TXalJPBPHkEAangns84lfqGzVlSUeCdDbikrqoPhvC4HlsUI74XGgdzpIeqSvZs1MvB3FvLwN1d3AuZXKb1E/qw3Zih9pSG7nJauoX9W4fg3vdbpx6Q/2pTlSAaX7fpv3qGWAZGAEOAC3gKL0xDfwAZtUXwDdgG1AHEnAItarOqZ/diJb6RN3XZ4M96gP1e653Ub2qVlOGXAZ2AluAVeBdSmklt3oTIKVUz+VHYvIa8DGl9KHP1r3R7RYNQmkw5e/Q8z1QTwPbc+n8e9DGUkrpfiHn+COHxctC4v8UfgH+YI1qigrwsQAAAABJRU5ErkJggg== +law-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC/klEQVRIieWVz2sUZxjHP887UzcqRmixelC6SSZhN9HdtQUvKq5SouKhFBo8qAextJU2Jj14sCKlgiiIYNAUWrAtvbSiByk9SC/Z/AHWbHfXjOusLq1CWjD422ycmacHk20Ss13TnsTvaXje5/v9PDO8PAMvlZx46ksnnuifi8eeG0LjIHNy1O1u6UhskVCSE90fPOXwNYAazZYKv138T4BYLPaaL5F+YHudGc7aWvnYdd3bNQFObPUGJPwFmDfjvILqF8uXvXo8k8n4Tjw5AOANZzem02n75sjofkQ+ByIzfOOo6fTcy4M2QMQaz1UC+wgQRdgNXBP4XoLwfLGYcz332ckymYwPHG1d+eYFguA9hV1AK8q3QDlijecADEChUBj13OxhFW5O+JcFFftUsZibJXq6ruV/HdYnDX3AUp4m/uG52cOFQmG0CgCIRqMNAh8BeWCRmefvqRdelT32PtAI5FH2RqPRhsmjKsCe37gTWIJKN+ggwr50Ov0c17jLQvgEyGjIPmCJPb9xxzMAwXQDOc8dGlTMSeCNWyOj79SLdzquvgs0IXKydDU7AAyB9DJxgQxAW3z124omVOUEoKXhoZ9ASqFITz2AhNID3PCutP4MICp9wMrm9sSmKiAg7EX5Kxi7c3bCFwKnBda3dSTX1Apvia16S2GdoH1wLgBQ/8EPwIhR6QUwzfFUq8BWoL9cLo9Nmq3w8RngbhjSXXN6zKfA/aDyyneTNc/zKoh8BWxra1sVsw10AgZhbUs8ud2IlkNhYRDIOiAANtf+PnQCvon4B5z21CUleCBqNaFsBCS0rE22//jOGXvB4tdRPhT4UVUQBYQQJYPowVr5oZFtRvUQSg+qDYIBFJASogf9R3e/mbKLuiynvZhAdAWBuRfY/vCNfP7PqYFTV8XUeiKRWPjwCU2i1qLQMreuFy79XvOt/01OPDkwCXlemfot/08117XTnupCtXlG97T/wT91ue5dGTo3W07tVaD6GZCaXqs+HZvRexmYFfDi629RIBtl1zP+PwAAAABJRU5ErkJggg== logo-gist-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAAYCAYAAACWTY9zAAAABmJLR0QA/wD/AP+gvaeTAAACoElEQVRIie2WX2hXZRjHP8/4hW5UzoGFja2wdiF00S7EotAgIcKbMBoIu7HuuhANC3aRCTIQgmBBsEZ3s8hoIwomrLuB4E0YhqWFrjHY0NY2rGHh6uPFecXj2flt+/2mXoRfOHDO+z5/Ps973vO8B+7r/yi1U+1XL6gL6j/qZfWU+r7aca9YIgf1LtAL/AuMAOeA68AjQCfwLDAeEU8l+wpwCDgfEV/fFTq120zfq49XsWlVu3PPu5LPX2vI+4L6RrXJJnVanVEfqyHow+rnas8awH5Vx6pN7k2Vv1dvgnq1HFgD8Eq6//IOJ21QN6rr6w1wRp2t0/c3tb8w1qmeVK95S3+oJ9K2aVF7037+L9ldzF1dABWgHRivqypoBppyUB3AGHAFOAZMAY3Ao8DzwDqytzQBfAIcBa4CH+Rinr0J9mCaLFuRfcADheHvIqJaIftSzOci4soyBQ2k+O8A0xExUDSokPUyqwToAx4qjL1O9RVuA2ZWgFqVGoAFslUrUzvQkq7XVhHvZ6BVfUttWtF6BbBJ4ImyyYiYj4i5iJgDVtNI+4BvgI+BP9Vx9Vv1iNpWK9hPwCa1FK4WRcRCRLxKVmgXMEh2xPUAP6pbawEbTfd71gqWA5yIiKGIOJxAdwIbgDdrARsG5oC31eY7BVcAPQ38DZQdeQ2lYBExDxwBWoGv1JYaci6S+0OpJnUHsB74pTA1C2xRiy2JCkBEfJT+Kg4CF9QTZHtvkeyL3Qy8XJLzErBdfSYiflD3A9sSwO9kPfBpoBuYAT4t+H8BfAgMqceB+YgYpSj1JXVYnfd2Takj6oH861ZfVCfVwfS8Wx1Tr+Z8Z9XP1CdL8kWKOZ5sZ0qKX+LUlA7hyipslxzUyb/YnJeL0ahW66f3taJuAAWd129KkzycAAAAAElFTkSuQmCC logo-gist-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAAYCAYAAACWTY9zAAAABmJLR0QA/wD/AP+gvaeTAAAD2ElEQVRIie2WS2xUVRjHf9/tHSgjRCA+C5am3gFmhnIHJypEY4aVujAYfCUGF8SFRhONgiRsiInxFRIiGhcSxQVEhYhhRQyycuMjUWaG0mnLLQ9BUORRiK20nTl/F1DSDJe2VOLC+F/dc873/c/vfDfnAf/rPyIb2Qgy4SI5njNjKTAb8BG9GBHSbmfeZwcrxQP/KlgqnVsj9CZQM9jlYL+JIYxbgEXAYrBDUaUYABQKBf/Yb2dXy6yzp1Lceb3BfIAgE66Q9C7wcw1v+aHK3iP1gXPnhrOcr6XD7eO/nytgvG2oD5g6kclbs+H9npgbdZQ2XwHWlM8n6a+uB51ucA2PRF17j8eZdHeXfgW2Drfd0KQflRj43OT2TQQKwHN8KjgBXAl2Q9/QMpndZti6rqtAxSmKfjgPPD1RqLHkyexhAM9p+/X2bm5rm9HS0tI4kWQL0uFeYE5UKc281uQgHR6W+Lqns/T85b5MuAjHWxgFYBjqDLCnP+mvnHHhQuNALbEK00Nc3FQDGCP/1Nqoo7TdB5qBQxNZFTDdjORwozWdSyF9i8dJnN4R3nHPc1PkvFsxd58/ODh5aKjRk1c7guwjQ29gnJez9cMeDa5Whou7cipwPm7WVCZc6ZwlRvb5ct90dZVjF+IZKxG+fC3pKZdPjrKgTQBBOnxNcKKns7ipPsAHDENx2RIbzTRtZF+tgSe4WoWlOxCnxoAalzygz1z8OVSdbM2Dvps56LuZJh4b081UwZgVpHMvNOXzyTHjR5EPHJXREjd4uFjsHf4O5od/jmWW9NnYP6R7gA+T/dUP7kyHvxi0Y/aTvOonPe3tR8cL5gEdwM3z5+di4a5F5XK5L6qUH63htZj0JLAFVENaa7WGfakFd6XH6+Vj7EY8VUXLgQ3/FA7g0pV2BNgBEKQXLgb7Tq72LLB6PB5edZJ9BZzFeLUll5t+PcDqFVXK3wMXJGuqHzPDiwU7XCz2mul1YFZiQF9ms9lrOGhVpe7pFKdUuu0BoNGM7rqhMxKt+Xw+UZ/jAxzoKL8fZMI5Eq8MOL8rSIfbwDokqoamGna7TA/WJ5vsoIx7U9lc7sD+YjGVWfiSZHcbdGP84SDhwQKJFcApBt3HdRZfGGw491d1R5AJt+LojTpLuy+DAUQdpVWtmYW7GmQvClaAbrRLtXDohGFFYHM1YXsu1wtbg7RFTquAZ5DXI/Q4sAwxzQDBWUw78dy6qNJ+bCRVVCm9F6RzkvSywTZMp4GbRi1/Uz6fbG5rm1EoFPxRA4G4i7opn0/OmzdvWlx8nGbPXjIlm81O6F33vwD+Bhvyhr7wtSBQAAAAAElFTkSuQmCC mark-github-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAB8ElEQVRIibWVu09UQRSHv9k1IgW7AY0RBWKsTGx9ND4qS5F/wEYLDb0xAY0UxkdrZ2NHYWfsaYyVT4iJwZpNjJooLBQEYz6LvZsdxmH3LtFfN3PO+X5zzzwu/GeFbkF1BJgCJoHjwFgRagDLwAvgeQjhR1+u6qA6q67ZW6vqjDpYFn5YfV0CnOqDOtELPqY2dgFvq6Ee6daWd1HyvPqyBPSV+jQav1H3tbkhMpgF7hXDLaAeQthUzwFXgE/AF0BgFDgBPAshLKhV4CcwVNTPhBAexKsfcfuGbqhdT1imA1+j+lV1GKBSxKeAWpRfTca94HuB+BTVgcuxwWRS8zCEsFbWIISwBcwl0x2m+jnZuKNl4RHjQMJYjoPNJFju0vxt8itiNKHTomqSO7wLeA3YE01VYoPvSf7Jfg2AU8n4W2zwPglO78Igrekw1enMDb1fXKCuUivq7Uz99Tiprq6rvwuzhSLpo3pLvZABn1Vv2nrkUjWLPdlWMFcEF9WD6tuo4EnG4HEG3Nad3KcOqEtRe/bb+ic8Uo9l8i/tAF9UB3bq54StJ3dTvaGOqofM3IuiRalW1PH8bnUKx4tVxLqYyTuf5Czl4JV0IoSwApwB7gLr7enMWtpzG7TeodNFbXmpNfWq6YloxYbUa2q9L+i/1h8/EAGdUrF9ZQAAAABJRU5ErkJggg== diff --git a/pkg/octicons/required_icons.txt b/pkg/octicons/required_icons.txt index 15dc444956..ebc5d99048 100644 --- a/pkg/octicons/required_icons.txt +++ b/pkg/octicons/required_icons.txt @@ -29,6 +29,7 @@ git-commit git-merge git-pull-request issue-opened +law logo-gist mark-github organization diff --git a/pkg/raw/raw.go b/pkg/raw/raw.go index fcaa5c6af2..3bf77b29e7 100644 --- a/pkg/raw/raw.go +++ b/pkg/raw/raw.go @@ -8,7 +8,7 @@ import ( "net/url" "strings" - gogithub "github.com/google/go-github/v89/github" + gogithub "github.com/google/go-github/v92/github" ) // errPathTraversal is returned when an owner, repo, ref/sha, or path diff --git a/pkg/raw/raw_test.go b/pkg/raw/raw_test.go index 8f6f54f4c7..4269ade1ea 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/v89/github" + "github.com/google/go-github/v92/github" "github.com/stretchr/testify/require" ) diff --git a/pkg/sanitize/sanitize.go b/pkg/sanitize/sanitize.go index 80d367029b..6321b8513d 100644 --- a/pkg/sanitize/sanitize.go +++ b/pkg/sanitize/sanitize.go @@ -1,16 +1,23 @@ package sanitize import ( + stdhtml "html" + "strconv" "strings" "sync" "unicode" "unicode/utf8" "github.com/microcosm-cc/bluemonday" + nethtml "golang.org/x/net/html" ) -var policy *bluemonday.Policy -var policyOnce sync.Once +var ( + policy *bluemonday.Policy + policyOnce sync.Once + plainTextPolicy *bluemonday.Policy + plainTextPolicyOnce sync.Once +) func Sanitize(input string) string { // The invisible-character and code-fence filters both run before and after @@ -36,6 +43,110 @@ func Sanitize(input string) string { return FilterCodeFenceMetadata(FilterInvisibleCharacters(normalized)) } +func Content(input string) string { + return FilterInvisibleCharacters(input) +} + +// PlainText sanitizes user-authored text that must not contain HTML. +func PlainText(input string) string { + filtered := FilterCodeFenceMetadata(FilterInvisibleCharacters(input)) + if filtered == "" { + return "" + } + + tokenizer := nethtml.NewTokenizer(strings.NewReader(filtered)) + var marked strings.Builder + var text []string + for { + tokenType := tokenizer.Next() + if tokenType == nethtml.ErrorToken { + break + } + if tokenType == nethtml.TextToken { + marker := plainTextMarker(len(text)) + marked.WriteString(marker) + text = append(text, neutralizePlainTextAngles(tokenizer.Token().Data)) + continue + } + marked.Write(tokenizer.Raw()) + } + + sanitized := restorePlainText(getPlainTextPolicy().Sanitize(marked.String()), text) + return FilterCodeFenceMetadata(FilterInvisibleCharacters(sanitized)) +} + +const plainTextMarkerPrefix = "githubmcpplaintexttoken" + +func plainTextMarker(index int) string { + return plainTextMarkerPrefix + strconv.Itoa(index) + "x" +} + +func restorePlainText(marked string, values []string) string { + var restored strings.Builder + for marked != "" { + start := strings.Index(marked, plainTextMarkerPrefix) + if start < 0 { + restored.WriteString(marked) + break + } + restored.WriteString(marked[:start]) + marked = marked[start+len(plainTextMarkerPrefix):] + + end := strings.IndexByte(marked, 'x') + if end < 0 { + restored.WriteString(plainTextMarkerPrefix) + restored.WriteString(marked) + break + } + index, err := strconv.Atoi(marked[:end]) + if err != nil || index < 0 || index >= len(values) { + restored.WriteString(plainTextMarkerPrefix) + restored.WriteString(marked[:end+1]) + } else { + restored.WriteString(values[index]) + } + marked = marked[end+1:] + } + return restored.String() +} + +func neutralizePlainTextAngles(input string) string { + input = FilterInvisibleCharacters(input) + input = strings.ReplaceAll(input, "\x00", string(utf8.RuneError)) + input = strings.ReplaceAll(input, "\r\n", "\n") + input = strings.ReplaceAll(input, "\r", "\n") + input = neutralizeNestedEntities(input) + input = strings.ReplaceAll(input, "<", "<") + return strings.ReplaceAll(input, ">", ">") +} + +func neutralizeNestedEntities(input string) string { + var neutralized strings.Builder + for { + start := strings.IndexByte(input, '&') + if start < 0 { + neutralized.WriteString(input) + return neutralized.String() + } + neutralized.WriteString(input[:start]) + input = input[start:] + + end := strings.IndexByte(input[1:], '&') + if end < 0 { + end = len(input) + } else { + end++ + } + if candidate := input[:end]; stdhtml.UnescapeString(candidate) != candidate { + neutralized.WriteString("&") + input = input[1:] + } else { + neutralized.WriteByte('&') + input = input[1:] + } + } +} + // FilterInvisibleCharacters removes invisible or control characters that should not appear // in user-facing titles or bodies. This includes: // - Unicode tag characters: U+E0001, U+E0020–U+E007F @@ -335,6 +446,13 @@ func getPolicy() *bluemonday.Policy { return policy } +func getPlainTextPolicy() *bluemonday.Policy { + plainTextPolicyOnce.Do(func() { + plainTextPolicy = bluemonday.StrictPolicy() + }) + return plainTextPolicy +} + func shouldRemoveRune(r rune) bool { switch r { case 0x200B, // ZERO WIDTH SPACE diff --git a/pkg/sanitize/sanitize_test.go b/pkg/sanitize/sanitize_test.go index 2b54bdb5f9..8ab772c799 100644 --- a/pkg/sanitize/sanitize_test.go +++ b/pkg/sanitize/sanitize_test.go @@ -365,6 +365,123 @@ func TestSanitizeRemovesInvisibleCodeFenceMetadata(t *testing.T) { assert.Equal(t, expected, result) } +func TestPlainText(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "visible punctuation", + input: `can't "quote" AT&T`, + expected: `can't "quote" AT&T`, + }, + { + name: "named punctuation entities", + input: `can't "quote" AT&T`, + expected: `can't "quote" AT&T`, + }, + { + name: "decimal punctuation entities", + input: "can't "quote" AT&T", + expected: `can't "quote" AT&T`, + }, + { + name: "hexadecimal punctuation entities", + input: "can't "quote" AT&T", + expected: `can't "quote" AT&T`, + }, + { + name: "raw unsafe element", + input: "beforeafter", + expected: "beforeafter", + }, + { + name: "raw formatting element", + input: "bold and italic", + expected: "bold and italic", + }, + { + name: "named entity encoded element remains inert", + input: "<script>alert(1)</script>", + expected: "<script>alert(1)</script>", + }, + { + name: "decimal entity encoded element remains inert", + input: "<script>alert(1)</script>", + expected: "<script>alert(1)</script>", + }, + { + name: "hexadecimal entity encoded element remains inert", + input: "<script>alert(1)</script>", + expected: "<script>alert(1)</script>", + }, + { + name: "double encoded element remains inert", + input: "&lt;script&gt;", + expected: "&lt;script&gt;", + }, + { + name: "triply encoded element remains inert", + input: "&amp;lt;script&amp;gt;", + expected: "&amp;lt;script&amp;gt;", + }, + { + name: "double encoded punctuation remains encoded once", + input: "can&#39;t", + expected: "can&#39;t", + }, + { + name: "malformed element is stripped", + input: "before bold", + expected: "githubmcpplaintexttoken1xbold", + }, + { + name: "literal angle brackets are neutralized", + input: "1 < 2 > 0", + expected: "1 < 2 > 0", + }, + { + name: "literal invisible and bidi characters", + input: "Hello\u200B\u202EWorld", + expected: "HelloWorld", + }, + { + name: "encoded invisible and bidi characters", + input: "Hello​‮World", + expected: "HelloWorld", + }, + { + name: "nul characters are normalized", + input: "Hello\x00�World", + expected: "Hello��World", + }, + { + name: "malformed utf8 is normalized", + input: "Hello\xffWorld", + expected: "Hello\uFFFDWorld", + }, + { + name: "code fence metadata", + input: "```steal secrets\ncode\n```", + expected: "```\ncode\n```", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := PlainText(tt.input) + require.Equal(t, tt.expected, result) + require.Equal(t, result, PlainText(result)) + }) + } +} + // TestSanitizeFiltersInvisibleCharactersAfterEntityDecoding covers the core // regression from issue #3101: invisible/bidi characters encoded as HTML // character entities are decoded by FilterHTMLTags, so the invisible-character @@ -665,6 +782,25 @@ func TestSanitizeIsIdempotent(t *testing.T) { } } +func TestPlainTextIsIdempotent(t *testing.T) { + for _, in := range invariantCorpus { + once := PlainText(in) + require.Equal(t, once, PlainText(once), "PlainText not idempotent on %q", in) + } +} + +func FuzzPlainTextIsIdempotent(f *testing.F) { + for _, seed := range invariantCorpus { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, in string) { + once := PlainText(in) + if twice := PlainText(once); twice != once { + t.Fatalf("PlainText not idempotent on %q: first %q, second %q", in, once, twice) + } + }) +} + // TestSanitizeDoesNotAllocateForCleanASCII pins the allocation contract from // issue #3117: ordinary clean text passes through without being copied. func TestSanitizeDoesNotAllocateForCleanASCII(t *testing.T) { @@ -722,3 +858,18 @@ func TestSanitizeStillStripsMaliciousContent(t *testing.T) { } var sink string + +func TestContentPreservesMarkdownAndCode(t *testing.T) { + content := "普通 prose with $5 and $x^2$, :rocket:, ✈️, 👩‍💻.\n\n" + + "[link](https://example.com/a?b=c) ![badge](https://example.com/b.svg)\n\n" + + "
Details
cell
\n\n" + + "```uncommon-language\nx & y\n```\n\n" + + " inline `code` and footnote[^1]\n\n[^1]: note" + + require.Equal(t, content, Content(content)) +} + +func TestContentRemovesOnlyUnconditionalInvisibleCharacters(t *testing.T) { + require.Equal(t, "left right", Content("left\u200B right")) + require.Equal(t, "✈️ and 👩‍💻", Content("✈️ and 👩‍💻")) +} diff --git a/pkg/scopes/fetcher.go b/pkg/scopes/fetcher.go index b372455031..5701c4afa2 100644 --- a/pkg/scopes/fetcher.go +++ b/pkg/scopes/fetcher.go @@ -8,8 +8,8 @@ import ( "strings" "time" - "github.com/github/github-mcp-server/pkg/http/headers" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/utils" ) // OAuthScopesHeader is the HTTP response header containing the token's OAuth scopes. diff --git a/pkg/scopes/map.go b/pkg/scopes/map.go index ab6a7867e2..77b96ae598 100644 --- a/pkg/scopes/map.go +++ b/pkg/scopes/map.go @@ -1,6 +1,10 @@ package scopes -import "github.com/github/github-mcp-server/pkg/inventory" +import ( + "encoding/json" + + "github.com/github/github-mcp-server/v2/pkg/inventory" +) // ToolScopeMap maps tool names to their complete scope access policies. type ToolScopeMap map[string]inventory.ScopeAccess @@ -8,8 +12,9 @@ type ToolScopeMap map[string]inventory.ScopeAccess // ToolScopeAccess is the immutable request-time subset of a tool scope policy. // Its maximum scopes stay private so callers cannot mutate the global lookup. type ToolScopeAccess struct { - maxScopes []string - challenge inventory.ScopeChallenge + maxScopes []string + challenge inventory.ScopeChallenge + argumentNormalizer inventory.InputNormalizer } var globalToolScopeMap map[string]ToolScopeAccess @@ -34,8 +39,9 @@ func SetGlobalToolScopeMap(m ToolScopeMap) { panic("dynamic scope challenge requires exhaustive maximum scopes") } globalToolScopeMap[name] = ToolScopeAccess{ - maxScopes: append([]string(nil), access.Scopes...), - challenge: access.Challenge, + maxScopes: append([]string(nil), access.Scopes...), + challenge: access.Challenge, + argumentNormalizer: access.ArgumentNormalizer, } } } @@ -57,6 +63,19 @@ func (access ToolScopeAccess) ResolveChallenge(arguments map[string]any, activeS return access.challenge(arguments, activeScopes) } +// NormalizeArguments applies the tool's compatibility normalizer before +// evaluating its dynamic scope challenge. +func (access ToolScopeAccess) NormalizeArguments(arguments json.RawMessage) (json.RawMessage, bool, error) { + if access.argumentNormalizer == nil { + return arguments, false, nil + } + if len(arguments) == 0 { + arguments = json.RawMessage(`{}`) + } + normalized, err := access.argumentNormalizer(arguments) + return normalized, true, err +} + // MaximumScopes returns a copy of the exhaustive upper bound. func (access ToolScopeAccess) MaximumScopes() []string { return append([]string(nil), access.maxScopes...) @@ -69,6 +88,9 @@ func GetToolScopeMapFromInventory(inv *inventory.Inventory) ToolScopeMap { if tool.ScopeAccess.Challenge != nil { access := tool.ScopeAccess access.Scopes = append([]string(nil), access.Scopes...) + if access.Dynamic { + access.ArgumentNormalizer = tool.GetInputNormalizer() + } result[tool.Tool.Name] = access } } diff --git a/pkg/scopes/map_test.go b/pkg/scopes/map_test.go index 4db83b1295..f3c366af5a 100644 --- a/pkg/scopes/map_test.go +++ b/pkg/scopes/map_test.go @@ -3,7 +3,7 @@ package scopes import ( "testing" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go index d845cc6dc8..709fd050f0 100644 --- a/pkg/scopes/scopes.go +++ b/pkg/scopes/scopes.go @@ -1,7 +1,7 @@ package scopes import ( - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" ) // Scope represents a GitHub OAuth scope. @@ -31,6 +31,14 @@ const ( // AdminOrg grants full control of organizations and teams AdminOrg Scope = "admin:org" + // ReadEnterprise grants read-only access to enterprise profile data, including + // enterprise-level custom properties + ReadEnterprise Scope = "read:enterprise" + + // AdminEnterprise grants full control of enterprises, including enterprise-level + // rulesets and custom properties + AdminEnterprise Scope = "admin:enterprise" + // Gist grants write access to gists Gist Scope = "gist" @@ -77,6 +85,9 @@ var oauthScopeDefinitions = []oauthScopeDefinition{ {scope: Repo, byDefault: true}, {scope: DeleteRepo}, {scope: ReadOrg, byDefault: true}, + {scope: AdminOrg}, + {scope: ReadEnterprise}, + {scope: AdminEnterprise}, {scope: ReadUser, byDefault: true}, {scope: UserEmail, byDefault: true}, {scope: ReadPackages, byDefault: true}, @@ -113,12 +124,13 @@ func oauthScopes(defaultOnly bool) []string { // A parent scope implicitly grants access to all child scopes. // For example, "repo" grants access to "public_repo" and "security_events". var ScopeHierarchy = map[Scope][]Scope{ - Repo: {PublicRepo, SecurityEvents}, - AdminOrg: {WriteOrg, ReadOrg}, - WriteOrg: {ReadOrg}, - Project: {ReadProject}, - WritePackages: {ReadPackages}, - User: {ReadUser, UserEmail}, + Repo: {PublicRepo, SecurityEvents}, + AdminOrg: {WriteOrg, ReadOrg}, + AdminEnterprise: {ReadEnterprise}, + WriteOrg: {ReadOrg}, + Project: {ReadProject}, + WritePackages: {ReadPackages}, + User: {ReadUser, UserEmail}, } // RequireAll creates scope checks for a tool that always needs the given scopes. diff --git a/pkg/scopes/scopes_test.go b/pkg/scopes/scopes_test.go index 8240e8454b..e9deda9953 100644 --- a/pkg/scopes/scopes_test.go +++ b/pkg/scopes/scopes_test.go @@ -17,15 +17,26 @@ func TestOAuthScopeCatalog(t *testing.T) { assert.NotContains(t, defaults, string(Workflow)) assert.Contains(t, supported, string(Codespace)) assert.NotContains(t, defaults, string(Codespace)) + assert.Contains(t, supported, string(AdminOrg)) + assert.NotContains(t, defaults, string(AdminOrg)) + assert.Contains(t, supported, string(ReadEnterprise)) + assert.NotContains(t, defaults, string(ReadEnterprise)) + assert.Contains(t, supported, string(AdminEnterprise)) + assert.NotContains(t, defaults, string(AdminEnterprise)) } func TestScopeChecks(t *testing.T) { assert.True(t, HasAll([]string{"repo", "workflow"}, Repo, Workflow)) + assert.True(t, HasAll([]string{"repo"}, PublicRepo)) assert.False(t, HasAll([]string{"repo"}, Repo, Workflow)) assert.True(t, HasAll([]string{"admin:org"}, ReadOrg)) assert.True(t, HasAllScopeNames([]string{"admin:org"}, []string{"read:org"})) + assert.True(t, HasAll([]string{"admin:enterprise"}, ReadEnterprise)) + assert.True(t, HasAllScopeNames([]string{"admin:enterprise"}, []string{"read:enterprise"})) + assert.False(t, HasAll([]string{"read:enterprise"}, AdminEnterprise)) assert.False(t, HasAllScopeNames([]string{"repo"}, []string{"repo", "workflow"})) assert.Nil(t, ChallengeAll([]string{"repo", "workflow"}, Repo, Workflow)) + assert.Nil(t, ChallengeAll([]string{"admin:enterprise"}, ReadEnterprise)) assert.Equal(t, []string{"repo", "workflow"}, ChallengeAll([]string{"repo"}, Repo, Workflow)) } @@ -52,6 +63,7 @@ func TestScopeHierarchy(t *testing.T) { assert.Contains(t, ScopeHierarchy[Repo], SecurityEvents) assert.Contains(t, ScopeHierarchy[AdminOrg], WriteOrg) assert.Contains(t, ScopeHierarchy[AdminOrg], ReadOrg) + assert.Contains(t, ScopeHierarchy[AdminEnterprise], ReadEnterprise) assert.Contains(t, ScopeHierarchy[WriteOrg], ReadOrg) assert.Contains(t, ScopeHierarchy[Project], ReadProject) assert.Contains(t, ScopeHierarchy[WritePackages], ReadPackages) diff --git a/pkg/toolvalidation/readonlyhint.go b/pkg/toolvalidation/readonlyhint.go index bcde92a5ec..b9877bf847 100644 --- a/pkg/toolvalidation/readonlyhint.go +++ b/pkg/toolvalidation/readonlyhint.go @@ -58,7 +58,7 @@ func (v ReadOnlyHintViolation) String() string { // 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 { + pkgs, err := parser.ParseDir(fset, dir, func(info os.FileInfo) bool { //nolint:staticcheck // SA1019: This source-level validator intentionally scans all files regardless of build tags, without loading packages. // 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") diff --git a/pkg/toolvalidation/readonlyhint_test.go b/pkg/toolvalidation/readonlyhint_test.go index 7ef3c4829b..56e48863ed 100644 --- a/pkg/toolvalidation/readonlyhint_test.go +++ b/pkg/toolvalidation/readonlyhint_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/pkg/toolvalidation" + "github.com/github/github-mcp-server/v2/pkg/toolvalidation" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/utils/token.go b/pkg/utils/token.go index 8933fb0bda..6bca4d8da1 100644 --- a/pkg/utils/token.go +++ b/pkg/utils/token.go @@ -6,8 +6,8 @@ import ( "regexp" "strings" - httpheaders "github.com/github/github-mcp-server/pkg/http/headers" - "github.com/github/github-mcp-server/pkg/http/mark" + httpheaders "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/mark" ) type TokenType int diff --git a/script/build-ui b/script/build-ui index a68f6764ec..a3a9179cdd 100755 --- a/script/build-ui +++ b/script/build-ui @@ -10,6 +10,9 @@ if [ ! -d "node_modules" ]; then npm install fi +echo "Testing UI..." +npm test + echo "Building UI..." npm run build diff --git a/script/lint b/script/lint index 5b69cbe2ff..00cfeb1aa0 100755 --- a/script/lint +++ b/script/lint @@ -6,9 +6,9 @@ gofmt -s -w . BINDIR="$(git rev-parse --show-toplevel)"/bin BINARY=$BINDIR/golangci-lint # sync with .github/workflows/lint.yml -GOLANGCI_LINT_VERSION=v2.9.0 +GOLANGCI_LINT_VERSION=v2.14.0 -if [ ! -f "$BINARY" ]; then +if [ ! -f "$BINARY" ] || ! "$BINARY" version --short | grep -qx "${GOLANGCI_LINT_VERSION#v}"; then curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b "$BINDIR" "$GOLANGCI_LINT_VERSION" fi diff --git a/script/print-mcp-diff-configs/main.go b/script/print-mcp-diff-configs/main.go index 421c9fce41..7648e5f831 100644 --- a/script/print-mcp-diff-configs/main.go +++ b/script/print-mcp-diff-configs/main.go @@ -36,8 +36,8 @@ import ( "sort" "strings" - "github.com/github/github-mcp-server/pkg/github" - mcphdr "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/github" + mcphdr "github.com/github/github-mcp-server/v2/pkg/http/headers" ) type config struct { @@ -141,7 +141,7 @@ func baseEntries() []baseEntry { }}, } - flags := append([]string(nil), github.AllowedFeatureFlags...) + flags := github.HeaderAllowedFeatureFlags() sort.Strings(flags) for _, f := range flags { entries = append(entries, baseEntry{ @@ -208,7 +208,7 @@ func (s settings) toHeaders() map[string]string { } func firstFeatureFlag() string { - flags := append([]string(nil), github.AllowedFeatureFlags...) + flags := github.HeaderAllowedFeatureFlags() if len(flags) == 0 { return "" } diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index f1d33c5130..5868e15ae9 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -14,25 +14,25 @@ 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/github/github-mcp-server/v2](https://pkg.go.dev/github.com/github/github-mcp-server/v2) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.2/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/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/34349a88bac3/LICENSE)) + - [github.com/google/go-github/v92/github](https://pkg.go.dev/github.com/google/go-github/v92/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v92.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/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) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.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.8.0/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.8.0/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [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/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/2402fdf4a9ed/LICENSE)) + - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/7ee5256398cf/LICENSE)) - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) - [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.15.0/LICENSE.txt)) - [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.10.0/LICENSE)) @@ -42,11 +42,11 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/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.55.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.36.0:LICENSE)) - - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.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.45.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.37.0:LICENSE)) + - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.59.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.37.0:LICENSE)) + - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.23.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.48.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.42.0:LICENSE)) - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.15.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 bd98a92cb8..b305cf81e5 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -14,25 +14,25 @@ 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/github/github-mcp-server/v2](https://pkg.go.dev/github.com/github/github-mcp-server/v2) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.2/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/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/34349a88bac3/LICENSE)) + - [github.com/google/go-github/v92/github](https://pkg.go.dev/github.com/google/go-github/v92/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v92.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/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) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.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.8.0/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.8.0/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [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/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/2402fdf4a9ed/LICENSE)) + - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/7ee5256398cf/LICENSE)) - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) - [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.15.0/LICENSE.txt)) - [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.10.0/LICENSE)) @@ -42,11 +42,11 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/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.55.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.36.0:LICENSE)) - - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.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.45.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.37.0:LICENSE)) + - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.59.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.37.0:LICENSE)) + - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.23.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.48.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.42.0:LICENSE)) - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.15.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 05086f41c2..cd62776f35 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -14,10 +14,10 @@ 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/github/github-mcp-server/v2](https://pkg.go.dev/github.com/github/github-mcp-server/v2) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.2/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/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/34349a88bac3/LICENSE)) + - [github.com/google/go-github/v92/github](https://pkg.go.dev/github.com/google/go-github/v92/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v92.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)) @@ -25,15 +25,15 @@ The following packages are included for the 386, amd64, arm64 architectures. - [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) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.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.8.0/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.8.0/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [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/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/2402fdf4a9ed/LICENSE)) + - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/7ee5256398cf/LICENSE)) - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) - [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.15.0/LICENSE.txt)) - [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.10.0/LICENSE)) @@ -43,11 +43,11 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/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.55.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.36.0:LICENSE)) - - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.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.45.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.37.0:LICENSE)) + - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.59.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.37.0:LICENSE)) + - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.23.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.48.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.42.0:LICENSE)) - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.15.0:LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party/github.com/github/github-mcp-server/LICENSE b/third-party/github.com/github/github-mcp-server/v2/LICENSE similarity index 100% rename from third-party/github.com/github/github-mcp-server/LICENSE rename to third-party/github.com/github/github-mcp-server/v2/LICENSE diff --git a/third-party/github.com/google/go-github/v89/github/LICENSE b/third-party/github.com/google/go-github/v92/github/LICENSE similarity index 100% rename from third-party/github.com/google/go-github/v89/github/LICENSE rename to third-party/github.com/google/go-github/v92/github/LICENSE diff --git a/third-party/golang.org/x/net/html/LICENSE b/third-party/golang.org/x/net/LICENSE similarity index 100% rename from third-party/golang.org/x/net/html/LICENSE rename to third-party/golang.org/x/net/LICENSE diff --git a/ui/package-lock.json b/ui/package-lock.json index d3a08adc3e..4211483e2b 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -9,489 +9,103 @@ "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", + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/ext-apps": "^2.0.3", + "@primer/octicons-react": "^19.38.0", + "@primer/primitives": "^11.10.0", + "@primer/react": "^38.40.1", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "zod": "^4.2.0" }, "devDependencies": { - "@types/node": "^25.2.0", - "@types/react": "^18.0.0", - "@types/react-dom": "^18.0.0", - "@vitejs/plugin-react": "^6.0.2", - "typescript": "^5.7.0", - "vite": "^8.0.16", + "@types/node": "^26.6.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "typescript": "^7.0.2", + "vite": "^8.3.2", "vite-plugin-singlefile": "^2.3.3" }, "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/types": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^26.0.0" } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emotion/is-prop-valid": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", - "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@emotion/memoize": "^0.9.0" - } - }, - "node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@emotion/stylis": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz", - "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@emotion/unitless": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", - "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", - "license": "MIT", - "peer": true - }, - "node_modules/@github/combobox-nav": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.3.1.tgz", - "integrity": "sha512-gwxPzLw8XKecy1nP63i9lOBritS3bWmxl02UX6G0TwMQZbMem1BCS1tEZgYd3mkrkiDrUMWaX+DbFCuDFo3K+A==", - "license": "MIT" - }, "node_modules/@github/markdown-toolbar-element": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@github/markdown-toolbar-element/-/markdown-toolbar-element-2.2.3.tgz", "integrity": "sha512-AlquKGee+IWiAMYVB0xyHFZRMnu4n3X4HTvJHu79GiVJ1ojTukCWyxMlF5NMsecoLcBKsuBhx3QPv2vkE/zQ0A==", "license": "MIT" }, - "node_modules/@github/paste-markdown": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@github/paste-markdown/-/paste-markdown-1.5.3.tgz", - "integrity": "sha512-PzZ1b3PaqBzYqbT4fwKEhiORf38h2OcGp2+JdXNNM7inZ7egaSmfmhyNkQILpqWfS0AYtRS3CDq6z03eZ8yOMQ==", + "node_modules/@github/mini-throttle": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@github/mini-throttle/-/mini-throttle-2.1.1.tgz", + "integrity": "sha512-KtOPaB+FiKJ6jcKm9UKyaM5fPURHGf+xcp+b4Mzoi81hOc6M1sIGpMZMAVbNzfa2lW5+RPGKq888Px0j76OZ/A==", "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==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@github/relative-time-element/-/relative-time-element-5.3.1.tgz", + "integrity": "sha512-f++QCIXxGH7rMbpTASRfLyCyPydd4HqwOmATBb/sAUCq72HEZN0MX9znmJSp7NKYxuQxF8j0Mv3ez9P32c70vw==", "license": "MIT" }, - "node_modules/@hono/node-server": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", - "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "hono": "^4" - } + "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/@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/@lit-labs/ssr-dom-shim": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", + "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", + "license": "BSD-3-Clause" }, - "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==", + "node_modules/@modelcontextprotocol/client": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.2.0.tgz", + "integrity": "sha512-LxCou/CSYQ6dwEnjhLZY0KnEuc8V4UJ3IEQCl/uR2yHobSQIEdJKUlKLRYRF5i5FqRGHy1eK7une3aAWDHLtig==", "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, + "@modelcontextprotocol/core": "2.2.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, "engines": { - "node": ">=6.0.0" + "node": ">=20" } }, - "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==", + "node_modules/@modelcontextprotocol/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.2.0.tgz", + "integrity": "sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw==", "license": "MIT", - "peer": true, "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" } }, - "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==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-2.0.3.tgz", + "integrity": "sha512-Tn+4+cyhO4f4cQSDxNyLiIfEE+qlTTuIbWBJB8JTinEXOhDYgCT4wDTQplxD9MBYZH5pCYGC59GaxiwIT4bu8w==", "license": "MIT", "workspaces": [ "examples/*" @@ -503,12 +117,17 @@ "node": ">=20" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/core": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.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" + "zod": "^4.2.0" }, "peerDependenciesMeta": { + "@modelcontextprotocol/server": { + "optional": true + }, "react": { "optional": true }, @@ -517,163 +136,92 @@ } } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } + "node_modules/@oddbird/popover-polyfill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@oddbird/popover-polyfill/-/popover-polyfill-0.5.2.tgz", + "integrity": "sha512-iFrvar5SOMtKFOSjYvs4z9UlLqDdJbMx0mgISLcPedv+g0ac5sgeETLGtipHCVIae6HJPclNEH5aCyD1RZaEHw==", + "license": "BSD-3-Clause" }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.2" - }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@oddbird/popover-polyfill": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@oddbird/popover-polyfill/-/popover-polyfill-0.3.8.tgz", - "integrity": "sha512-+aK7EHL3VggfsWGVqUwvtli2+kP5OWyseAsrefhzR2XWoi2oALUCeoDn63i5WS3ZOmLiXHRNBwHPeta8w+aM1g==", - "license": "BSD-3-Clause" - }, - "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "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==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.11.0.tgz", + "integrity": "sha512-kDHtaw9kfCjPVT61eM731ftckhGyWiPX+OKmUccLN9sMFt9E6FXmsPAbny7O0RBxeW1Xj/NHNdbMEjYEg2dBYg==", "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==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@primer/live-region-element/-/live-region-element-0.8.1.tgz", + "integrity": "sha512-g7bNs9R2A2LfETMOWYsvtT0b5Q/QLM1Fz1vrTcWa5r2LbEhrPm7cZV5pEOleFKiGybrYWmdXteV2sBAQLXuxvg==", "license": "MIT", "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.2.0" + "@lit-labs/ssr-dom-shim": "^1.6.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==", + "version": "19.38.0", + "resolved": "https://registry.npmjs.org/@primer/octicons-react/-/octicons-react-19.38.0.tgz", + "integrity": "sha512-ZzWVrX6kluBaCepsDj/jAxagBOMaRX18LEf723IKpr4E/jdkTdCOtlsN+60Oj7IV9YEVrlUpo9Oky0Tn1heSCA==", "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" + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-11.10.0.tgz", + "integrity": "sha512-ihZxIst4Izry284StKFjpbYvX6R/IBFTLu+qiuAmR93PsKF2hdjoabhSYwKI8iCxOUx94jVP55bI3NoY7STjYQ==", + "license": "MIT", + "workspaces": [ + ".", + "docs/storybook" + ] }, "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==", + "version": "38.40.1", + "resolved": "https://registry.npmjs.org/@primer/react/-/react-38.40.1.tgz", + "integrity": "sha512-yTd+QTtWXdqJbWQLCnLibwh280j2TKw1+tREEXtJR6lRYiqrE1dnu5ifCsKhsqIYvxqaKZBNWg4KRqiFG6opZA==", "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", + "@github/mini-throttle": "^2.1.1", + "@github/relative-time-element": "^5.0.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", + "@oddbird/popover-polyfill": "^0.5.2", + "@primer/behaviors": "^1.10.3", + "@primer/live-region-element": "^0.8.0", + "@primer/octicons-react": "^19.28.1", + "@primer/primitives": "10.x || 11.x", + "@tanstack/react-virtual": "^3.13.18", + "clsx": "^2.1.1", "color2k": "^2.0.3", - "deepmerge": "^4.2.2", - "focus-visible": "^5.2.0", - "fzy.js": "^0.4.1", + "deepmerge": "^4.3.1", + "focus-visible": "^5.2.1", "history": "^5.0.0", + "hsluv": "1.0.1", "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" + "react-intersection-observer": "^10.0.3" }, "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" + "@types/react": "18.x || 19.x", + "@types/react-dom": "18.x || 19.x", + "@types/react-is": "18.x || 19.x", + "react": "18.x || 19.x", + "react-dom": "18.x || 19.x", + "react-is": "18.x || 19.x" }, "peerDependenciesMeta": { "@types/react": { @@ -681,759 +229,33 @@ }, "@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" + }, + "@types/react-is": { + "optional": true + } } }, - "node_modules/@primer/react/node_modules/vfile-message": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-3.1.4.tgz", - "integrity": "sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==", + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-stringify-position": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", "cpu": [ "arm64" ], @@ -1448,9 +270,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", "cpu": [ "arm64" ], @@ -1465,9 +287,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", "cpu": [ "x64" ], @@ -1482,9 +304,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", "cpu": [ "x64" ], @@ -1499,9 +321,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", "cpu": [ "arm" ], @@ -1516,9 +338,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", "cpu": [ "arm64" ], @@ -1533,9 +355,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", "cpu": [ "arm64" ], @@ -1550,9 +372,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", "cpu": [ "ppc64" ], @@ -1567,9 +389,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", "cpu": [ "s390x" ], @@ -1584,9 +406,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", "cpu": [ "x64" ], @@ -1601,9 +423,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", "cpu": [ "x64" ], @@ -1618,9 +440,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", "cpu": [ "arm64" ], @@ -1634,29 +456,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", "cpu": [ "arm64" ], @@ -1671,9 +474,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", "cpu": [ "x64" ], @@ -1700,287 +503,470 @@ "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==", + "node_modules/@tanstack/react-virtual": { + "version": "3.14.13", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.13.tgz", + "integrity": "sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg==", "license": "MIT", "dependencies": { - "@styled-system/core": "^5.1.2" + "@tanstack/virtual-core": "3.17.11" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "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==", + "node_modules/@tanstack/virtual-core": { + "version": "3.17.11", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.11.tgz", + "integrity": "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==", "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, - "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==", + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", "license": "MIT", "dependencies": { - "object-assign": "^4.1.1" + "@types/ms": "*" } }, - "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==", + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "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==", + "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": { - "@styled-system/core": "^5.1.2" + "@types/estree": "*" } }, - "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==", + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "license": "MIT", "dependencies": { - "@styled-system/core": "^5.1.2" + "@types/unist": "*" } }, - "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==", + "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": { - "@styled-system/core": "^5.1.2" + "@types/unist": "*" } }, - "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==", + "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": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "dev": true, "license": "MIT", "dependencies": { - "@styled-system/core": "^5.1.2" + "undici-types": "~8.9.0" } }, - "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==", + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "license": "MIT", "dependencies": { - "styled-system": "^5.1.5" + "csstype": "^3.2.2" } }, - "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==", + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "devOptional": true, "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" + "peerDependencies": { + "@types/react": "^19.3.0" } }, - "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/@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/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" } }, - "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/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" } }, - "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/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" } }, - "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/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" } }, - "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==", + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.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/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" } }, - "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/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } }, - "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/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" } }, - "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/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" } }, - "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/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" } }, - "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/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } }, - "node_modules/@types/node": { - "version": "25.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", - "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.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/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" } }, - "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/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.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/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" } }, - "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/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" } }, - "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/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" } }, - "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/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } }, "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==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", "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==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.0" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1988,6 +974,7 @@ "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -1996,75 +983,12 @@ }, "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": { + }, + "oxc-transform-react": { "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", @@ -2075,58 +999,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.37", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", - "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "license": "MIT", - "peer": true, - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/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/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -2140,112 +1012,6 @@ "node": ">=8" } }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/camelize": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", - "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0", - "peer": true - }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -2297,18 +1063,18 @@ } }, "node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "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==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/color2k/-/color2k-2.0.4.tgz", + "integrity": "sha512-OXAPGFRNeLFnUfqDtloYdxkwsJoIdXe28+bjbpJiPqyei2HPa3VHmMCWa0Qe62+U4Ftf9Hj7hRssOkxz7WiWbg==", "license": "MIT" }, "node_modules/comma-separated-tokens": { @@ -2317,77 +1083,8 @@ "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" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/cross-spawn": { @@ -2395,7 +1092,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", @@ -2405,28 +1101,6 @@ "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", @@ -2472,16 +1146,6 @@ "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", @@ -2514,104 +1178,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/diff": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", - "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT", - "peer": true - }, - "node_modules/electron-to-chromium": { - "version": "1.5.372", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", - "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", - "license": "ISC", - "peer": true - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT", - "peer": true - }, "node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -2634,22 +1200,11 @@ "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" }, @@ -2658,107 +1213,37 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", - "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", "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==", + "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", - "peer": true - }, - "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true } - ], - "license": "BSD-3-Clause", - "peer": true + } }, "node_modules/fill-range": { "version": "7.1.1", @@ -2773,54 +1258,12 @@ "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", @@ -2836,120 +1279,6 @@ "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", @@ -2999,32 +1328,11 @@ "@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.13.3", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", - "integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.9.0" - } + "node_modules/hsluv": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hsluv/-/hsluv-1.0.1.tgz", + "integrity": "sha512-zCaFTiDqBLQjCCFBu0qg7z9ASYPd+Bxx2GDCVZJsnehjK80S+jByqhuFz0pCd2Aw3FSKr18AWbRlwnKR0YdizQ==", + "license": "MIT" }, "node_modules/html-url-attributes": { "version": "3.0.1", @@ -3036,77 +1344,12 @@ "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.5.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", - "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", - "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", @@ -3131,29 +1374,6 @@ "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", @@ -3192,93 +1412,29 @@ "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 + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "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 + "license": "ISC" }, "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", "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==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -3292,23 +1448,23 @@ "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" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.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==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -3327,9 +1483,9 @@ } }, "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==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -3348,9 +1504,9 @@ } }, "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==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -3369,9 +1525,9 @@ } }, "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==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -3390,9 +1546,9 @@ } }, "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==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -3411,9 +1567,9 @@ } }, "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==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -3432,9 +1588,9 @@ } }, "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==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -3453,9 +1609,9 @@ } }, "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==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -3474,9 +1630,9 @@ } }, "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==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -3495,9 +1651,9 @@ } }, "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==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -3516,9 +1672,9 @@ } }, "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==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -3536,13 +1692,6 @@ "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", @@ -3565,28 +1714,6 @@ "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", @@ -3597,88 +1724,6 @@ "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", @@ -3696,9 +1741,9 @@ } }, "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==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -3773,9 +1818,9 @@ } }, "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==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.1.tgz", + "integrity": "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -3916,9 +1961,9 @@ } }, "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==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.3.tgz", + "integrity": "sha512-wgyJtgUkUcdU7zci7uuwc/tzoAhm0TswWhaXuvnWolqug+8jY7bgNkBBL0dYBnHgc/tHL7lCzRdPsdUxUKKBqw==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -3926,8 +1971,10 @@ "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", + "micromark-util-character": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" }, @@ -3949,33 +1996,10 @@ "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==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", "funding": [ { "type": "GitHub Sponsors", @@ -3998,6 +2022,7 @@ "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", @@ -4008,9 +2033,9 @@ } }, "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==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", "funding": [ { "type": "GitHub Sponsors", @@ -4027,18 +2052,19 @@ "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", + "micromark-factory-space": "^2.1.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-edit-map": "^1.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" + "micromark-util-types": "^2.0.3" } }, "node_modules/micromark-extension-gfm": { @@ -4116,9 +2142,9 @@ } }, "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==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", "license": "MIT", "dependencies": { "devlop": "^1.0.0", @@ -4206,9 +2232,9 @@ } }, "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==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", "funding": [ { "type": "GitHub Sponsors", @@ -4390,6 +2416,25 @@ "micromark-util-symbol": "^2.0.0" } }, + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", + "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-encode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", @@ -4520,9 +2565,9 @@ "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==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", "funding": [ { "type": "GitHub Sponsors", @@ -4549,40 +2594,17 @@ "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==", + "node_modules/micromatch/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==", + "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "mime-db": "^1.54.0" - }, "engines": { - "node": ">=18" + "node": ">=8.6" }, "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" + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/ms": { @@ -4592,9 +2614,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -4610,71 +2632,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "peer": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "peer": true, - "dependencies": { - "wrappy": "1" - } - }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -4700,50 +2657,30 @@ "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==", + "dev": true, "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==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -4754,15 +2691,14 @@ "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.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -4776,137 +2712,53 @@ { "type": "github", "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.17", - "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, + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, "engines": { - "node": ">= 0.6" + "node": "^10 || ^12 || >=14" } }, - "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==", + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", "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" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "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==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "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==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.3.0" } }, "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==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-10.1.0.tgz", + "integrity": "sha512-V8HDu3+Llg6OEhOxx8LnUSS0t4VS+1Xk9ZatkI8Jct/H0CwKnqFTCu8NT3q7ghJTghTdIrEMPSWr2dkKPG+gdQ==", "license": "MIT", "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", @@ -4919,9 +2771,9 @@ } }, "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==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", "license": "MIT", "peer": true }, @@ -5018,24 +2870,14 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5045,145 +2887,34 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "license": "MIT", - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" } }, - "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 + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" }, "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" }, @@ -5196,91 +2927,14 @@ "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==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5297,16 +2951,6 @@ "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", @@ -5339,71 +2983,6 @@ "inline-style-parser": "0.2.7" } }, - "node_modules/styled-components": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.11.tgz", - "integrity": "sha512-uuzIIfnVkagcVHv9nE0VPlHPSCmXIUGKfJ42LNjxCCTDTL5sgnJ8Z7GZBq0EnLYGln77tPpEpExt2+qa+cZqSw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-imports": "^7.0.0", - "@babel/traverse": "^7.4.5", - "@emotion/is-prop-valid": "^1.1.0", - "@emotion/stylis": "^0.8.4", - "@emotion/unitless": "^0.7.4", - "babel-plugin-styled-components": ">= 1.12.0", - "css-to-react-native": "^3.0.0", - "hoist-non-react-statics": "^3.0.0", - "shallowequal": "^1.1.0", - "supports-color": "^5.5.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/styled-components" - }, - "peerDependencies": { - "react": ">= 16.8.0", - "react-dom": ">= 16.8.0", - "react-is": ">= 16.8.0" - } - }, - "node_modules/styled-system": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/styled-system/-/styled-system-5.1.5.tgz", - "integrity": "sha512-7VoD0o2R3RKzOzPK0jYrVnS8iJdfkKsQJNiLRDjikOpQVqQHns/DXWaPZOH4tIKkhAT7I6wIsy9FWTWh2X3q+A==", - "license": "MIT", - "dependencies": { - "@styled-system/background": "^5.1.2", - "@styled-system/border": "^5.1.5", - "@styled-system/color": "^5.1.2", - "@styled-system/core": "^5.1.2", - "@styled-system/flexbox": "^5.1.2", - "@styled-system/grid": "^5.1.2", - "@styled-system/layout": "^5.1.2", - "@styled-system/position": "^5.1.2", - "@styled-system/shadow": "^5.1.2", - "@styled-system/space": "^5.1.2", - "@styled-system/typography": "^5.1.2", - "@styled-system/variant": "^5.1.5", - "object-assign": "^4.1.1" - } - }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", - "peer": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -5421,37 +3000,6 @@ "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", @@ -5465,16 +3013,6 @@ "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", @@ -5495,65 +3033,45 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/type-is": { - "version": "2.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==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "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==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT" }, @@ -5576,16 +3094,6 @@ "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", @@ -5654,75 +3162,6 @@ "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", @@ -5752,16 +3191,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "bin": { @@ -5778,7 +3217,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5851,25 +3290,11 @@ } } }, - "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" }, @@ -5880,40 +3305,15 @@ "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==", + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "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", diff --git a/ui/package.json b/ui/package.json index 9644b72d9e..039cd12b0c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,31 +5,35 @@ "type": "module", "description": "MCP App UIs for github-mcp-server using Primer React", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^26.0.0" }, "scripts": { "build": "node scripts/build.mjs", "dev": "npm run build", "typecheck": "tsc --noEmit", + "test": "node --experimental-strip-types --test src/lib/*.test.ts", "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", + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/ext-apps": "^2.0.3", + "@primer/octicons-react": "^19.38.0", + "@primer/primitives": "^11.10.0", + "@primer/react": "^38.40.1", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "zod": "^4.2.0" }, "devDependencies": { - "@types/node": "^25.2.0", - "@types/react": "^18.0.0", - "@types/react-dom": "^18.0.0", - "@vitejs/plugin-react": "^6.0.2", - "typescript": "^5.7.0", - "vite": "^8.0.16", + "@types/node": "^26.6.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "typescript": "^7.0.2", + "vite": "^8.3.2", "vite-plugin-singlefile": "^2.3.3" } } diff --git a/ui/src/apps/get-me/App.tsx b/ui/src/apps/get-me/App.tsx index c181fcab90..27ba66a86d 100644 --- a/ui/src/apps/get-me/App.tsx +++ b/ui/src/apps/get-me/App.tsx @@ -1,7 +1,7 @@ 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 { Avatar, Text, Link, Heading, Spinner } from "@primer/react"; import { OrganizationIcon, LocationIcon, @@ -35,30 +35,30 @@ function AvatarWithFallback({ src, login, size }: { src?: string; login: string; if (!src || imgError) { return ( - - + ); } return ( - setImgError(true)} + style={{ marginRight: 16 }} /> ); } @@ -79,43 +79,62 @@ function UserCard({ }); return ( - {/* Header with avatar and name */} - +
- - +
+ {d.name || user.login} - @{user.login} - - + @{user.login} +
+
{/* Info grid */} - +
{d.company && ( <> - +
{d.company} )} {d.location && ( <> - +
{d.location} )} {d.blog && ( <> - +
- +
{d.email} )} - +
{/* Stats */} - - - +
+
+ {d.public_repos ?? 0} - Repos - - - + Repos +
+
+ {d.followers ?? 0} - Followers - - - + Followers +
+
+ {d.following ?? 0} - Following - - - + Following +
+
+ ); } @@ -165,25 +194,25 @@ function GetMeApp() { const content = (() => { if (error) { - return Error: {error.message}; + return Error: {error.message}; } if (!toolResult) { return ( - +
- Loading user data... - + 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; + 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 Failed to parse user data; } })(); diff --git a/ui/src/apps/issue-write/App.tsx b/ui/src/apps/issue-write/App.tsx index ee48ea5d33..5cfb5d83a4 100644 --- a/ui/src/apps/issue-write/App.tsx +++ b/ui/src/apps/issue-write/App.tsx @@ -1,11 +1,10 @@ import { StrictMode, useState, useCallback, useEffect, useMemo, useRef } from "react"; import { createRoot } from "react-dom/client"; import { - Box, Text, TextInput, Button, - Flash, + Banner, Spinner, FormControl, CounterLabel, @@ -13,6 +12,7 @@ import { ActionList, Label, } from "@primer/react"; +import styles from "../../styles.module.css"; import { IssueOpenedIcon, CheckCircleIcon, @@ -25,6 +25,7 @@ import { import { AppProvider } from "../../components/AppProvider"; import { useMcpApp } from "../../hooks/useMcpApp"; import { completedToolResult } from "../../lib/toolResult"; +import { parseUIGetResult } from "../../lib/uiGetResult"; import { MarkdownEditor } from "../../components/MarkdownEditor"; interface IssueResult { @@ -296,46 +297,65 @@ function SuccessView({ 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} {submittedLabels.length > 0 && ( - +
{submittedLabels.map((label) => (
)} -
- - +
+
+ ); } @@ -520,7 +553,7 @@ function CreateIssueApp() { (c: { type: string }) => c.type === "text" ); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); + const data = parseUIGetResult("labels", textContent.text as string); const labels = (data.labels || []).map( (l: { name: string; color: string; id: string }) => ({ id: l.id || l.name, @@ -547,7 +580,7 @@ function CreateIssueApp() { (c: { type: string }) => c.type === "text" ); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); + const data = parseUIGetResult("assignees", textContent.text as string); const assignees = (data.assignees || []).map( (a: { login: string }) => ({ id: a.login, @@ -573,7 +606,7 @@ function CreateIssueApp() { (c: { type: string }) => c.type === "text" ); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); + const data = parseUIGetResult("milestones", textContent.text as string); const milestones = (data.milestones || []).map( (m: { number: number; title: string; description: string }) => ({ id: String(m.number), @@ -601,10 +634,8 @@ function CreateIssueApp() { (c: { type: string }) => c.type === "text" ); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); - // ui_get returns array directly or wrapped in issue_types/types - const typesArray = Array.isArray(data) ? data : (data.issue_types || data.types || []); - const types = typesArray.map( + const typesArray = parseUIGetResult("issue_types", textContent.text as string); + const types = typesArray.filter(t => t !== null).map( (t: { id: number; name: string; description?: string } | string) => { if (typeof t === "string") { return { id: t, text: t }; @@ -631,7 +662,7 @@ function CreateIssueApp() { (c: { type: string }) => c.type === "text" ); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); + const data = parseUIGetResult("issue_fields", textContent.text as string); const fields = (data.fields || []) .map( (field: { @@ -1162,9 +1193,9 @@ function CreateIssueApp() { const selectedOptionName = fieldValue.cleared ? undefined : fieldValue.optionName; const selectedOption = field.options.find((option) => option.name === selectedOptionName); return ( - +
- + {selectedOption ? selectedOption.name : "Select option"} @@ -1179,15 +1210,15 @@ function CreateIssueApp() { onSelect={() => updateIssueFieldValue(field.name, { optionName: option.name })} > - @@ -1198,18 +1229,26 @@ function CreateIssueApp() { - +
); } return ( updateIssueFieldValue(field.name, { value: e.target.value })} + onChange={(e) => + updateIssueFieldValue(field.name, { value: e.target.value }) + } block contrast - sx={{ flex: 1 }} + style={{ flex: 1 }} /> ); }; @@ -1217,17 +1256,22 @@ function CreateIssueApp() { const body_node = (() => { if (appError) { return ( - - Connection error: {appError.message} - + ); } if (!app) { return ( - +
- +
); } @@ -1246,52 +1290,71 @@ function CreateIssueApp() { } return ( - - {/* Repository picker */} - - + {/* Repository picker */} +
+
span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }} - > + size="small" + leadingVisual={selectedRepo?.isPrivate ? LockIcon : RepoIcon} + style={{ maxWidth: "100%", overflow: "hidden" }} + className={styles.truncateLastChild} + > {selectedRepo ? selectedRepo.fullName : "Select repository"} - +
setRepoFilter(e.target.value)} - sx={{ width: "100%" }} - size="small" - autoFocus - /> - + placeholder="Search repositories..." + value={repoFilter} + onChange={(e) => setRepoFilter(e.target.value)} + size="small" + autoFocus + style={{ width: "100%" }} + /> +
{repoSearchLoading ? ( - +
- +
) : repoSearchResults.length > 0 ? ( repoSearchResults.map((r) => ( ) : ( - - +
+ Type to search repositories... - +
)}
- - +
+
{/* Error banner */} {error && ( - - {error} - + )} {/* Title */} - - + + Title {/* Description */} - +
+ as="label" + style={{ + fontWeight: 500, + fontSize: 14, + display: "block", + marginBottom: 8, + }} + > Description - +
{/* Metadata section */} - +
{/* Labels dropdown */} Labels {selectedLabels.length > 0 && ( - {selectedLabels.length} + {selectedLabels.length} )} - +
- +
{labelsLoading ? ( @@ -1420,14 +1509,14 @@ function CreateIssueApp() { }} > - +
{label.text} @@ -1442,11 +1531,18 @@ function CreateIssueApp() { Assignees {selectedAssignees.length > 0 && ( - {selectedAssignees.length} + {selectedAssignees.length} )} - +
- +
{assigneesLoading ? ( @@ -1569,18 +1665,23 @@ function CreateIssueApp() {
- +
{/* Fields section */} {availableIssueFields.length > 0 && ( - - +
+ Fields - +
{availableIssueFields.map((field) => { const fieldValue = fieldValues[field.name]; const hasFieldValue = @@ -1590,56 +1691,95 @@ function CreateIssueApp() { (fieldValue.value !== undefined && fieldValue.value !== "")); return ( - - +
+ {field.name} {field.description && ( - + {field.description} )} - +
{renderIssueFieldInput(field)} {hasFieldValue && ( )} - - +
+
); })} -
- +
+
)} {/* Selected labels display */} {selectedLabels.length > 0 && ( - +
{selectedLabels.map((label) => ( ))} - +
)} {/* Selected metadata display */} {(selectedAssignees.length > 0 || selectedMilestone) && ( - +
{selectedAssignees.length > 0 && ( Assigned to: {selectedAssignees.map((a) => a.text).join(", ")} @@ -1648,36 +1788,51 @@ function CreateIssueApp() { {selectedMilestone && ( Milestone: {selectedMilestone.text} )} - +
)} {/* State and submit actions */} - +
{isUpdateMode && ( - +
{currentState === "open" ? ( <> - - + size="small" + style={{ + marginLeft: "-1px", + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + }} + > {selectedStateReason.label} @@ -1695,18 +1850,18 @@ function CreateIssueApp() { - +
{stateReason === "duplicate" && ( - - Duplicate of + + Duplicate of setDuplicateOf(e.target.value)} - size="small" - sx={{ width: 140 }} - /> + type="number" + placeholder="Issue number" + value={duplicateOf} + onChange={(e) => setDuplicateOf(e.target.value)} + size="small" + style={{ width: 140 }} + /> )} @@ -1719,7 +1874,7 @@ function CreateIssueApp() { Reopen issue )} -
+
)} -
-
+
+ ); })(); diff --git a/ui/src/apps/pr-edit/App.tsx b/ui/src/apps/pr-edit/App.tsx index 5b4995affc..59e4e5ca09 100644 --- a/ui/src/apps/pr-edit/App.tsx +++ b/ui/src/apps/pr-edit/App.tsx @@ -1,11 +1,10 @@ import { StrictMode, useState, useCallback, useEffect, useMemo } from "react"; import { createRoot } from "react-dom/client"; import { - Box, Text, TextInput, Button, - Flash, + Banner, Spinner, FormControl, ActionMenu, @@ -15,6 +14,7 @@ import { CounterLabel, Label, } from "@primer/react"; +import styles from "../../styles.module.css"; import { GitPullRequestIcon, CheckCircleIcon, @@ -26,6 +26,7 @@ import { import { AppProvider } from "../../components/AppProvider"; import { useMcpApp } from "../../hooks/useMcpApp"; import { completedToolResult } from "../../lib/toolResult"; +import { parseUIGetResult } from "../../lib/uiGetResult"; import { MarkdownEditor } from "../../components/MarkdownEditor"; interface PRResult { @@ -169,46 +170,65 @@ function SuccessView({ const prUrl = pr.html_url || pr.url || pr.URL || "#"; return ( - - - +
- - +
+ Pull request updated successfully -
- - + + + ); } @@ -367,8 +393,8 @@ function EditPRApp() { if (result && !result.isError && result.content) { const textContent = result.content.find((c: { type: string }) => c.type === "text"); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); - const branches = (data.branches || data || []).map( + const data = parseUIGetResult("branches", textContent.text as string); + const branches = (data.branches || []).map( (b: { name: string; protected?: boolean }) => ({ name: b.name, protected: b.protected || false }) ); setAvailableBranches(branches); @@ -391,7 +417,7 @@ function EditPRApp() { if (result && !result.isError && result.content) { const textContent = result.content.find((c: { type: string }) => c.type === "text"); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); + const data = parseUIGetResult("reviewers", textContent.text as string); const users = (data.users || []).map( (u: { login: string; avatar_url?: string }) => ({ kind: "user" as const, @@ -516,9 +542,16 @@ function EditPRApp() { if (!app && !appError) { return ( - +
- +
); } @@ -526,7 +559,7 @@ function EditPRApp() { if (appError) { return ( - {appError.message} + ); } @@ -534,9 +567,16 @@ function EditPRApp() { if (toolInput === null) { return ( - +
- +
); } @@ -544,56 +584,78 @@ function EditPRApp() { if (!owner || !repo || !pullNumber) { return ( - Pull request owner, repo, and pullNumber are required. + ); } return ( - - - +
- - - #{pullNumber} · {owner}/{repo} +
+
+ #{pullNumber} · {owner}/{repo} {title && ( - + {title} )} - - +
+ - {error && {error}} + {error && } {isLoadingPR && !initialValues ? ( - +
- Loading pull request... - + Loading pull request... +
) : ( <> - - Title + + Title setTitle(e.target.value)} @@ -603,22 +665,42 @@ function EditPRApp() { /> - - +
+ Description - - - - base +
+ +
+ base - span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}> + {baseBranch || "Select base"} - +
- +
{branchesLoading ? ( Loading... @@ -647,11 +729,27 @@ function EditPRApp() {
- - - - - state +
+ +
+
+ state - - - +
+ + +
+ +
+ reviewers - + {selectedReviewers.length === 0 ? ( "No reviewers" ) : ( <> Reviewers - {selectedReviewers.length} + {selectedReviewers.length} )} - +
- +
{reviewersLoading ? ( Loading... @@ -734,28 +858,51 @@ function EditPRApp() {
{selectedReviewers.length > 0 && ( - +
{selectedReviewers.map((reviewer) => ( ))} - +
)} -
- - - +
+ +
+ - +
)} -
+
); } diff --git a/ui/src/apps/pr-write/App.tsx b/ui/src/apps/pr-write/App.tsx index 6975434bea..c800ae0cdc 100644 --- a/ui/src/apps/pr-write/App.tsx +++ b/ui/src/apps/pr-write/App.tsx @@ -1,11 +1,10 @@ import { StrictMode, useState, useCallback, useEffect, useMemo } from "react"; import { createRoot } from "react-dom/client"; import { - Box, Text, TextInput, Button, - Flash, + Banner, Spinner, FormControl, ActionMenu, @@ -15,6 +14,7 @@ import { CounterLabel, Label, } from "@primer/react"; +import styles from "../../styles.module.css"; import { GitPullRequestIcon, CheckCircleIcon, @@ -28,6 +28,7 @@ import { import { AppProvider } from "../../components/AppProvider"; import { useMcpApp } from "../../hooks/useMcpApp"; import { completedToolResult } from "../../lib/toolResult"; +import { parseUIGetResult } from "../../lib/uiGetResult"; import { MarkdownEditor } from "../../components/MarkdownEditor"; interface PRResult { @@ -82,46 +83,65 @@ function SuccessView({ const prUrl = pr.html_url || pr.url || pr.URL || "#"; return ( - - - +
- - +
+ Pull request created successfully -
- - + + + ); } @@ -310,8 +336,8 @@ function CreatePRApp() { if (result && !result.isError && result.content) { const textContent = result.content.find((c: { type: string }) => c.type === "text"); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); - const branches = (data.branches || data || []).map( + const data = parseUIGetResult("branches", textContent.text as string); + const branches = (data.branches || []).map( (b: { name: string; protected?: boolean }) => ({ name: b.name, protected: b.protected || false }) ); setAvailableBranches(branches); @@ -338,7 +364,7 @@ function CreatePRApp() { if (result && !result.isError && result.content) { const textContent = result.content.find((c: { type: string }) => c.type === "text"); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); + const data = parseUIGetResult("reviewers", textContent.text as string); const users = (data.users || []).map( (u: { login: string; avatar_url?: string }) => ({ kind: "user" as const, @@ -462,9 +488,16 @@ function CreatePRApp() { if (!app && !appError) { return ( - +
- +
); } @@ -472,59 +505,78 @@ function CreatePRApp() { if (appError) { return ( - {appError.message} + ); } return ( - {/* Repository picker */} - - +
span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }} + style={{ maxWidth: "100%", overflow: "hidden" }} + className={styles.truncateLastChild} > {selectedRepo ? selectedRepo.fullName : "Select repository"} - +
setRepoFilter(e.target.value)} - sx={{ width: "100%" }} - size="small" - autoFocus - /> - + placeholder="Search repositories..." + value={repoFilter} + onChange={(e) => setRepoFilter(e.target.value)} + size="small" + autoFocus + style={{ width: "100%" }} + /> +
{repoSearchLoading ? ( - +
- +
) : repoSearchResults.length > 0 ? ( repoSearchResults.map((r) => ( ) : ( - - Type to search repositories... - +
+ Type to search repositories... +
)}
- - +
+ {/* Branch selectors */} - - - base +
+
+ base - span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}> + {baseBranch || "Select base"} - +
- +
{branchesLoading ? ( Loading... @@ -604,19 +686,39 @@ function CreatePRApp() {
- - - ← +
- - compare + ← + +
+ compare - span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}> + {headBranch || "Select head"} - +
- +
{branchesLoading ? ( Loading... @@ -644,15 +746,15 @@ function CreatePRApp() {
- - +
+
{/* Error banner */} - {error && {error}} + {error && } {/* Title */} - - Title + + Title setTitle(e.target.value)} @@ -663,29 +765,55 @@ function CreatePRApp() { {/* Description */} - - +
+ Description - +
{/* Reviewers */} - - reviewers +
+ reviewers - + {selectedReviewers.length === 0 ? ( "No reviewers" ) : ( <> Reviewers - {selectedReviewers.length} + {selectedReviewers.length} )} - +
- +
{reviewersLoading ? ( Loading... @@ -737,29 +865,52 @@ function CreatePRApp() {
{selectedReviewers.length > 0 && ( - +
{selectedReviewers.map((reviewer) => ( ))} - +
)} -
+
{/* Options and Submit */} - - +
+ @@ -810,8 +967,8 @@ function CreatePRApp() { - - +
+
); } diff --git a/ui/src/components/AppProvider.tsx b/ui/src/components/AppProvider.tsx index e27bf96a02..9258ff99d6 100644 --- a/ui/src/components/AppProvider.tsx +++ b/ui/src/components/AppProvider.tsx @@ -1,4 +1,8 @@ -import { ThemeProvider, BaseStyles, Box } from "@primer/react"; +import { BaseStyles } from "@primer/react"; +import { ThemeProvider } from "@primer/react/next"; +import "@primer/primitives/dist/css/primitives.css"; +import "@primer/primitives/dist/css/functional/themes/light.css"; +import "@primer/primitives/dist/css/functional/themes/dark.css"; import type { ReactNode, CSSProperties } from "react"; import { useEffect, useMemo } from "react"; import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; @@ -44,10 +48,10 @@ export function AppProvider({ children, hostContext }: AppProviderProps) { return ( - +
{children} - +
); diff --git a/ui/src/components/FeedbackFooter.tsx b/ui/src/components/FeedbackFooter.tsx index 10fbdf44e6..c8f4c38403 100644 --- a/ui/src/components/FeedbackFooter.tsx +++ b/ui/src/components/FeedbackFooter.tsx @@ -1,17 +1,19 @@ -import { Box, Text } from "@primer/react"; +import { 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 index 5ba25932d0..fde12dae9a 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -6,7 +6,8 @@ * @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 { Text, Button, IconButton, useTheme } from "@primer/react"; +import styles from "../styles.module.css"; import { BoldIcon, ItalicIcon, @@ -25,7 +26,7 @@ import remarkGfm from "remark-gfm"; import "@github/markdown-toolbar-element"; // Declare types for the web component elements -declare global { +declare module "react/jsx-runtime" { namespace JSX { interface IntrinsicElements { "markdown-toolbar": React.DetailedHTMLProps< @@ -168,41 +169,48 @@ export function MarkdownEditor({ }; return ( - {/* Header with tabs and toolbar */} - {/* Write/Preview tabs */} - +
@@ -210,24 +218,32 @@ export function MarkdownEditor({ size="small" variant="invisible" onClick={() => setViewMode("preview")} - sx={{ - fontWeight: viewMode === "preview" ? "semibold" : "normal", - color: viewMode === "preview" ? "fg.default" : "fg.muted", - bg: viewMode === "preview" ? "actionListItem.default.hoverBg" : "transparent", - borderRadius: 2, - "&:hover": { - color: "fg.default", - }, + style={{ + fontWeight: viewMode === "preview" ? 500 : 400, + backgroundColor: + viewMode === "preview" + ? "var(--control-transparent-bgColor-hover)" + : "transparent", + borderRadius: 6, }} + className={styles.editorTab} + data-active={viewMode === "preview"} > Preview - +
{/* Toolbar - uses GitHub's official markdown-toolbar-element */} {viewMode === "write" && ( - +
- @@ -302,10 +319,10 @@ export function MarkdownEditor({ variant="invisible" /> - +
)} -
+ {/* Content area */} {viewMode === "write" ? ( @@ -333,115 +350,51 @@ export function MarkdownEditor({ }} /> ) : ( - :first-child": { mt: 0 }, - // GitHub Flavored Markdown styles - "& h1, & h2, & h3, & h4, & h5, & h6": { - mt: 3, - mb: 2, - fontWeight: "semibold", - lineHeight: 1.25, - }, - "& h1": { fontSize: 4, borderBottom: "1px solid", borderColor: "border.default", pb: 2 }, - "& h2": { fontSize: 3, borderBottom: "1px solid", borderColor: "border.default", pb: 2 }, - "& h3": { fontSize: 2 }, - "& p": { my: 2 }, - "& ul, & ol": { pl: 4, my: 2 }, - "& li": { my: 1 }, - "& code": { - bg: "neutral.muted", - px: 1, - py: "2px", - borderRadius: 1, - fontFamily: "mono", - fontSize: "85%", - }, - "& pre": { - bg: "neutral.muted", - p: 3, - borderRadius: 2, - overflow: "auto", - my: 2, - }, - "& pre code": { - bg: "transparent", - p: 0, - }, - "& blockquote": { - borderLeft: "4px solid", - borderColor: "border.default", - pl: 3, - ml: 0, - mr: 0, - my: 2, - color: "fg.muted", - bg: "canvas.subtle", - }, - "& a": { - color: "accent.fg", - textDecoration: "none", - "&:hover": { textDecoration: "underline" }, - }, - "& table": { - borderCollapse: "collapse", - width: "100%", - my: 2, - }, - "& th, & td": { - border: "1px solid", - borderColor: "border.default", - p: 2, - }, - "& th": { - bg: "canvas.subtle", - fontWeight: "semibold", - }, - "& input[type='checkbox']": { - mr: 2, - }, - "& hr": { - border: "none", - borderTop: "1px solid", - borderColor: "border.default", - my: 3, - }, - }}> + color: "var(--fgColor-default)", + }} + className={styles.markdownPreview} + > {value ? ( {value} ) : ( - + Nothing to preview )} - + )} {/* Footer */} - - + Markdown is supported - -
+ + ); } diff --git a/ui/src/hooks/useMcpApp.ts b/ui/src/hooks/useMcpApp.ts index e4146a234f..f45276abf0 100644 --- a/ui/src/hooks/useMcpApp.ts +++ b/ui/src/hooks/useMcpApp.ts @@ -5,7 +5,7 @@ import type { McpUiHostContext, McpUiUpdateModelContextRequest, } from "@modelcontextprotocol/ext-apps"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult } from "@modelcontextprotocol/client"; import { useState, useCallback, useEffect } from "react"; interface UseMcpAppOptions { diff --git a/ui/src/lib/toolResult.ts b/ui/src/lib/toolResult.ts index e985537f49..cb39015754 100644 --- a/ui/src/lib/toolResult.ts +++ b/ui/src/lib/toolResult.ts @@ -1,4 +1,4 @@ -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult } from "@modelcontextprotocol/client"; /** * Returns the parsed JSON payload of a *completed* tool result, or `null` when diff --git a/ui/src/lib/uiGetResult.test.ts b/ui/src/lib/uiGetResult.test.ts new file mode 100644 index 0000000000..570b4d7e25 --- /dev/null +++ b/ui/src/lib/uiGetResult.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { parseUIGetResult } from "./uiGetResult.ts"; + +const fixtures = { + labels: { labels: [{ id: "L", name: "bug", color: "red" }] }, + assignees: { assignees: [{ login: "octocat", avatar_url: "avatar.png" }] }, + milestones: { milestones: [{ number: 1, title: "v1", description: "Release" }] }, + issue_types: [{ id: 1, name: "Bug" }, null], + branches: { branches: [{ name: "main", protected: true }] }, + issue_fields: { fields: [{ id: "F", name: "Priority", data_type: "text" }] }, + reviewers: { + users: [{ login: "octocat", avatar_url: "avatar.png" }], + teams: [{ slug: "docs", org: "owner" }], + }, +}; + +for (const method of Object.keys(fixtures) as Array) { + test(`${method}: modern and legacy payloads reach the same picker data`, () => { + const legacy = parseUIGetResult(method, JSON.stringify(fixtures[method])); + const modern = parseUIGetResult(method, JSON.stringify({ method, [method]: fixtures[method] })); + assert.deepEqual(modern, legacy); + assert.deepEqual(modern, fixtures[method]); + }); +} + +test("picker arrays and reviewer avatars survive modern decoding", () => { + assert.equal(parseUIGetResult("labels", JSON.stringify({ method: "labels", labels: fixtures.labels })).labels.map(l => l.name)[0], "bug"); + assert.equal(parseUIGetResult("branches", JSON.stringify({ method: "branches", branches: fixtures.branches })).branches.map(b => b.name)[0], "main"); + const reviewers = parseUIGetResult("reviewers", JSON.stringify({ method: "reviewers", reviewers: fixtures.reviewers })); + assert.equal(reviewers.users.map(u => u.avatar_url)[0], "avatar.png"); + assert.equal(reviewers.teams.map(t => `${t.org}/${t.slug}`)[0], "owner/docs"); + assert.equal(parseUIGetResult("issue_fields", JSON.stringify({ method: "issue_fields", issue_fields: fixtures.issue_fields })).fields.map(f => f.name)[0], "Priority"); +}); + +test("issue types accept empty, null, wrapped, and nullable-item payloads", () => { + for (const value of [null, [], { method: "issue_types", issue_types: null }, { method: "issue_types", issue_types: [] }]) { + assert.deepEqual(parseUIGetResult("issue_types", JSON.stringify(value)), []); + } + for (const value of [{ issue_types: fixtures.issue_types }, { types: fixtures.issue_types }]) { + assert.deepEqual(parseUIGetResult("issue_types", JSON.stringify(value)), fixtures.issue_types); + } + const types = parseUIGetResult("issue_types", JSON.stringify({ method: "issue_types", issue_types: fixtures.issue_types })); + assert.equal(types.filter(t => t !== null).map(t => typeof t === "string" ? t : t.name)[0], "Bug"); +}); + +test("legacy bare branches and empty picker arrays remain supported", () => { + assert.deepEqual(parseUIGetResult("branches", JSON.stringify(fixtures.branches.branches)), fixtures.branches); + assert.deepEqual(parseUIGetResult("labels", '{"labels":[]}').labels, []); +}); + +test("malformed JSON and mismatched modern envelopes fail explicitly", () => { + assert.throws(() => parseUIGetResult("labels", "not JSON")); + assert.throws(() => parseUIGetResult("labels", '{"method":"branches","branches":{}}'), /Invalid ui_get/); + assert.throws(() => parseUIGetResult("labels", '{"method":"labels"}'), /Invalid ui_get/); +}); diff --git a/ui/src/lib/uiGetResult.ts b/ui/src/lib/uiGetResult.ts new file mode 100644 index 0000000000..8d07c3061d --- /dev/null +++ b/ui/src/lib/uiGetResult.ts @@ -0,0 +1,52 @@ +interface UIUser { + login: string; + avatar_url?: string; +} + +interface UIIssueType { + id: number; + name: string; + description?: string; +} + +interface UIGetPayloads { + labels: { labels: Array<{ id: string; name: string; color: string }> }; + assignees: { assignees: UIUser[] }; + milestones: { milestones: Array<{ number: number; title: string; description: string }> }; + issue_types: Array; + branches: { branches: Array<{ name: string; protected?: boolean }> }; + issue_fields: { fields: Array<{ + id?: string; + name?: string; + data_type?: string; + description?: string; + options?: Array<{ id?: string; name?: string; description?: string; color?: string }>; + }> }; + reviewers: { + users: UIUser[]; + teams: Array<{ slug: string; name?: string; org: string }>; + }; +} + +export function parseUIGetResult( + method: M, + text: string, +): UIGetPayloads[M] { + let payload: unknown = JSON.parse(text); + if (payload !== null && typeof payload === "object" && "method" in payload) { + if (payload.method !== method || !(method in payload)) { + throw new Error(`Invalid ui_get ${method} response envelope`); + } + payload = (payload as Record)[method]; + } + if (method === "issue_types") { + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + const wrapped = payload as Record; + payload = wrapped.issue_types ?? wrapped.types ?? []; + } + payload ??= []; + } else if (method === "branches" && Array.isArray(payload)) { + payload = { branches: payload }; + } + return payload as UIGetPayloads[M]; +} diff --git a/ui/src/styles.module.css b/ui/src/styles.module.css new file mode 100644 index 0000000000..cfe72d499b --- /dev/null +++ b/ui/src/styles.module.css @@ -0,0 +1,148 @@ +.truncateLastChild > span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.truncateChildren > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.editorTab { + color: var(--fgColor-muted); +} + +.editorTab[data-active="true"], +.editorTab:hover { + color: var(--fgColor-default); +} + +.markdownPreview > :first-child { + margin-top: 0; +} + +.markdownPreview h1, +.markdownPreview h2, +.markdownPreview h3, +.markdownPreview h4, +.markdownPreview h5, +.markdownPreview h6 { + margin-top: 16px; + margin-bottom: 8px; + font-weight: 500; + line-height: 1.25; +} + +.markdownPreview h1 { + font-size: 24px; + border-bottom: 1px solid; + border-color: var(--borderColor-default); + padding-bottom: 8px; +} + +.markdownPreview h2 { + font-size: 20px; + border-bottom: 1px solid; + border-color: var(--borderColor-default); + padding-bottom: 8px; +} + +.markdownPreview h3 { + font-size: 16px; +} + +.markdownPreview p { + margin-top: 8px; + margin-bottom: 8px; +} + +.markdownPreview ul, +.markdownPreview ol { + padding-left: 24px; + margin-top: 8px; + margin-bottom: 8px; +} + +.markdownPreview li { + margin-top: 4px; + margin-bottom: 4px; +} + +.markdownPreview code { + background-color: var(--bgColor-neutral-muted); + padding-left: 4px; + padding-right: 4px; + padding-top: 2px; + padding-bottom: 2px; + border-radius: 3px; + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-size: 85%; +} + +.markdownPreview pre { + background-color: var(--bgColor-neutral-muted); + padding: 16px; + border-radius: 6px; + overflow: auto; + margin-top: 8px; + margin-bottom: 8px; +} + +.markdownPreview pre code { + background-color: transparent; + padding: 0; +} + +.markdownPreview blockquote { + border-left: 4px solid; + border-color: var(--borderColor-default); + padding-left: 16px; + margin-left: 0; + margin-right: 0; + margin-top: 8px; + margin-bottom: 8px; + color: var(--fgColor-muted); + background-color: var(--bgColor-muted); +} + +.markdownPreview a { + color: var(--fgColor-accent); + text-decoration: none; +} +.markdownPreview a:hover { + text-decoration: underline; +} + +.markdownPreview table { + border-collapse: collapse; + width: 100%; + margin-top: 8px; + margin-bottom: 8px; +} + +.markdownPreview th, +.markdownPreview td { + border: 1px solid; + border-color: var(--borderColor-default); + padding: 8px; +} + +.markdownPreview th { + background-color: var(--bgColor-muted); + font-weight: 500; +} + +.markdownPreview input[type="checkbox"] { + margin-right: 8px; +} + +.markdownPreview hr { + border: none; + border-top: 1px solid; + border-color: var(--borderColor-default); + margin-top: 16px; + margin-bottom: 16px; +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 209c885166..3fe9878cfb 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -11,6 +11,7 @@ "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, + "allowImportingTsExtensions": true, "types": ["node"] }, "include": ["src", "vite.config.ts"]